Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,12 @@ dotnet_diagnostic.xUnit1031.severity = none
# The latter brings incosistency in the codebase and some times in one test case.
# So we are disabling this rule with respect to the above mentioned reasons.
dotnet_diagnostic.xUnit2013.severity = none

# Attributes needed for interop are not CLS-compliant, which produces CS3016 warnings. Disabling here doesn't
# actually work, but when we apply the [CLSCompliant(false)] attribute to the partial declarations of the generated
# types it then fires CS3019 warnings about it not making sense on internal types. We disable these warnings for
# anything in the CsWin32 subfolders. Other options are to disable CS3016 entirely in the project, but that would
# suppress the warning for all code, not just the interop code. Hopefully https://github.com/dotnet/roslyn/issues/68526
# is addressed so we can remove all of this complication.
[{**/Windows/**/*.cs}]
dotnet_diagnostic.CS3019.severity = none
106 changes: 106 additions & 0 deletions .github/skills/cswin32-com/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
name: cswin32-com
description: 'Guides struct-based COM interop in MSBuild using CsWin32 patterns. Consult when working with ComScope<T>, ComClassFactory, IComIID, IID.Get<T>(), delegate* unmanaged vtables, CoCreateInstance, or manually defining COM interfaces not in Win32 metadata (e.g. WMI IWbemLocator, IWbemServices).'
argument-hint: 'Describe the COM interface or activation pattern you are working with.'
---

# CsWin32 COM Interop Guide

Struct-based COM interop using CsWin32 patterns — AOT-compatible, no `[ComImport]` or built-in marshalling.

## Workflow

1. **Determine if the interface is in Win32 metadata.** If yes, add the name to `src/Framework/NativeMethods.txt` — CsWin32 generates it. If no (e.g. WMI), define a manual struct (see below).
2. **Create a `ComScope<T>`** for lifetime management: `using ComScope<T> scope = new();`
3. **Activate the COM object** via `ComClassFactory.TryCreate(CLSID, ...)` or `PInvoke.CoCreateInstance` with `IID.Get<T>()`.
4. **Call methods** via `scope.Pointer->Method(...)`. Pass `ComScope<T>` directly as `T**` output parameters.
5. **Guard with `#if FEATURE_WINDOWSINTEROP`** (or `&& NET` for manual structs needing `delegate* unmanaged`).

## COM Interfaces in Win32 Metadata

Add the interface name to `src/Framework/NativeMethods.txt` → CsWin32 generates it → use `ComScope<T>`:

```csharp
#if FEATURE_WINDOWSINTEROP
using ComScope<IRunningObjectTable> rot = new();
HRESULT hr = PInvoke.GetRunningObjectTable(0, rot);
if (hr.Failed) return;
rot.Pointer->SomeMethod(...);
#endif
```

## Manual COM Structs (Not in Metadata)

For interfaces not in Win32 metadata (e.g. WMI), define struct-based implementations in their own files, excluded via `<Compile Remove>` in source builds. Guard with `#if FEATURE_WINDOWSINTEROP && NET` (needs `delegate* unmanaged`).

```csharp
[SupportedOSPlatform("windows6.1")]
internal unsafe struct IWbemLocator : IComIID
{
public static Guid Guid { get; } = new(0xDC12A687, ...);

// .NET 7+ static abstract IComIID implementation
static ref readonly Guid IComIID.Guid
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
ReadOnlySpan<byte> data = [ /* 16 GUID bytes */ ];
return ref Unsafe.As<byte, Guid>(ref MemoryMarshal.GetReference(data));
}
}

private readonly void** _lpVtbl;

// IUnknown (vtable 0-2) + interface methods at correct indices
public HRESULT ConnectServer(char* strNetworkResource, ...) {
fixed (IWbemLocator* pThis = &this)
return ((delegate* unmanaged[Stdcall]<IWbemLocator*, char*, ..., HRESULT>)_lpVtbl[3])(pThis, ...);
}

public static Guid CLSID { get; } = new(0x4590F811, ...);
}
```

**Requirements:**
- `delegate* unmanaged[Stdcall]` — needs .NET 5+
- Exact vtable indices — unused slots can be omitted as long as used method indices are correct
- Dual `IComIID` — static abstract on .NET 7+, instance-based on net472 (polyfill in `src/Framework/Framework/`)
- `char*` with `fixed` for BSTR string parameters
- CS0592 prevents `[SupportedOSPlatform]` on structs — put on individual methods instead

## Activation

```csharp
// Via ComClassFactory (AOT-compatible)
if (ComClassFactory.TryCreate(IWbemLocator.CLSID, out var factory, out HRESULT hr))
using ComScope<IWbemLocator> instance = factory.TryCreateInstance<IWbemLocator>(out hr);

// Via CoCreateInstance — use IID.Get<T>() for the IID
Guid clsid = IWbemLocator.CLSID;
using ComScope<IWbemLocator> locator = new();
hr = PInvoke.CoCreateInstance(&clsid, null, CLSCTX.CLSCTX_INPROC_SERVER, IID.Get<IWbemLocator>(), locator);
```

**Key points:**
- Use `IID.Get<T>()` — do not take `&localGuid`
- Initialize `ComScope<T>` with `new()`. It implicitly converts to `T**` / `void**` output parameters

## Lifetime & Access

- `ComScope<T>` is a `ref struct` — use with `using`. Calls `Release()` on dispose.
- Access methods via `scope.Pointer->Method(...)`.
- Pass `ComScope<T>` directly as `T**` or `void**` output parameter (implicit conversion).

## File Organization

| Location | Contents |
|----------|----------|
| `src/Framework/Windows/Win32/System/Com/` | `ComScope.cs`, `ComClassFactory.cs` |
| `src/Framework/Windows/Win32/IID.cs` | Generic IID lookup |
| `src/Framework/Utilities/Wmi/` | Manual WMI structs (.NET-only) |
| `src/Framework/Framework/` | net472 `IComIID` polyfill |

## CS3016 CLS Compliance

CsWin32 COM structs trigger CS3016 under `[assembly: CLSCompliant(true)]`. Handled via `[CLSCompliant(false)]` partial declarations in `GeneratedInteropClsCompliance.cs`. CS3019 warnings suppressed in `.editorconfig` for `{**/Windows/**/*.cs}` — do not add per-file suppressions. See https://github.com/dotnet/roslyn/issues/68526.
112 changes: 112 additions & 0 deletions .github/skills/cswin32-interop/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
name: cswin32-interop
description: 'Guides CsWin32 P/Invoke interop in MSBuild. Consult when working with the PInvoke class, Windows.Win32 namespaces, FEATURE_WINDOWSINTEROP, HANDLE/HMODULE/HRESULT types, BufferScope<T>, replacing [DllImport] with CsWin32, or conditioning Windows-only code for source builds.'
argument-hint: 'Describe the Windows API or interop code you are migrating or adding.'
---

# CsWin32 Interop Guide

[CsWin32](https://github.com/microsoft/CsWin32) replaces `[DllImport]` with source-generated `PInvoke.*` calls. `FEATURE_WINDOWSINTEROP` is the compile-time gate; source builds disable it.

## Rules

1. **Replace `[DllImport]` with `PInvoke.*`**. Delete old declarations and hand-written structs/enums/constants.
2. **Gate with `#if FEATURE_WINDOWSINTEROP`**, add runtime `IsWindows` check inside. Both required.
3. **Use CsWin32 types directly** (`HANDLE`, `HMODULE`, `HRESULT.S_OK`, `FILE_FLAGS_AND_ATTRIBUTES`, etc.).
4. **Call `PInvoke.*` directly** — no wrappers. Types flow via `InternalsVisibleTo`.
5. **Prefer CsWin32 for Windows APIs**. Use `[LibraryImport]` only for non-Windows native calls (e.g. `libc`), guarded with `#if NET`.

### Dual Guard Pattern

```csharp
#if FEATURE_WINDOWSINTEROP
if (IsWindows)
{
PInvoke.GetFileAttributesEx(fullPath, out WIN32_FILE_ATTRIBUTE_DATA data);
}
#endif
// Cross-platform fallback
```

**WRONG**: `if (IsWindows) { #if FEATURE_WINDOWSINTEROP ... #endif }` — dead code in source builds.

Windows-only files are excluded via `<Compile Remove>` instead — no `#if` inside needed.

## Infrastructure

**Define**: `src/Directory.BeforeCommon.targets` sets `FEATURE_WINDOWSINTEROP` + `$(FeatureWindowsInterop)` when `DotNetBuildSourceOnly != true`. Use `$(FeatureWindowsInterop)` in `.csproj` for `<Compile Remove>`/`<Compile Include>`.

**CsWin32 config**: `src/Framework/NativeMethods.txt` (API list) + `NativeMethods.json` (`allowMarshaling: false`, `useSafeHandles: false`). Lives in Framework; other projects consume via `InternalsVisibleTo`. Do not add CsWin32 to other projects.

**Guard selection**:

| Guard | When | Runtime check? |
|-------|------|----------------|
| `#if FEATURE_WINDOWSINTEROP` | Multi-TFM Windows calls | Yes |
| `#if FEATURE_WINDOWSINTEROP && NET` | `delegate* unmanaged`, `ComScope<T>` | Yes |
| `#if FEATURE_WINDOWSINTEROP && !NETSTANDARD` | CsWin32 types without `static abstract` (net472 + net10) | Yes |
| `#if !NET` / `#if FEATURE_MSCOREE` | net472-only = inherently Windows | No |

**Namespace imports** must be inside `#if FEATURE_WINDOWSINTEROP`. WDK APIs use `Windows.Wdk` namespace.

**Files**: `src/Framework/Windows/` (CsWin32 partials), `src/Shared/Win32/` (COM helpers), `src/Framework/Utilities/Wmi/` (.NET-only COM structs), `src/Framework/Framework/` (net472 polyfills).

### Constant Replacements

`NativeMethodsShared.S_OK` → `HRESULT.S_OK`, `InvalidHandle` → `HANDLE.INVALID_HANDLE_VALUE`, `FILE_ATTRIBUTE_DIRECTORY` → `FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY`, `STD_OUTPUT_HANDLE` → `STD_HANDLE.STD_OUTPUT_HANDLE`, `GENERIC_READ` → `FILE_ACCESS_RIGHTS.FILE_GENERIC_READ`. Pattern: `CsWin32EnumType.ORIGINAL_NAME` — check generated types in `obj/`.

## BufferScope<T>

`BufferScope<T>` (`src/Framework/Utilities/BufferScope.cs`) — stackalloc initial buffer with `ArrayPool<T>` fallback. Lives in Framework, available to all projects via `InternalsVisibleTo`.

```csharp
using BufferScope<char> buffer = new(stackalloc char[(int)PInvoke.MAX_PATH]);
int length = (int)PInvoke.GetShortPathName(path, buffer.AsSpan());
if (length > buffer.Length)
{
buffer.EnsureCapacity(length);
length = (int)PInvoke.GetShortPathName(path, buffer.AsSpan());
}
if (length > 0) path = buffer.Slice(0, length).ToString();
```

- `ref struct` — always use with `using`. Never stack-allocate more than 1024 bytes.
- Check CsWin32 convenience overloads (e.g. `GetShortPathName(string, Span<char>)`) before writing `fixed` blocks.

## Gotchas

### CA1416 Platform Compatibility

No blanket `NoWarn` — handle semantically:
- `if (IsWindows)` satisfies `[SupportedOSPlatform]` — no pragma needed
- `if (IsUnixLike)` satisfies `[UnsupportedOSPlatform("windows")]`
- **Never use `!IsWindows`** — use `else if (IsUnixLike)`. See `documentation/specs/CA1416-analyzer-analysis.md`
- Use versioned `[SupportedOSPlatform("windows6.1")]` on methods calling CsWin32 APIs
- `#pragma warning disable CA1416` only for **static local functions** (analyzer limitation)
- CS0592 prevents `[SupportedOSPlatform]` on `partial struct` — put on individual members instead

### Type Conversions

- `HANDLE ↔ IntPtr`: `(HANDLE)intPtr` / `(IntPtr)h.Value`. Sentinels: `HANDLE.Null`, `HANDLE.INVALID_HANDLE_VALUE`
- `FILETIME → long`: `data.ftLastWriteTime.ToLong()` — CsWin32 uses `ComTypes.FILETIME` (int fields), not `Win32.Foundation.FILETIME`
- `SafeFileHandle`: `new SafeFileHandle((IntPtr)h.Value, true)`, pass with `(HANDLE)handle.DangerousGetHandle()`
- Nullable structs: `(SECURITY_ATTRIBUTES?)null`
- Enum flags: use bitwise `&` — `HasFlag()` boxes on .NET Framework
- Anonymous unions: `systemInfo.Anonymous.Anonymous.wProcessorArchitecture` — check generated source in `obj/`

### Source-Build Verification (REQUIRED before pushing)

Source builds (`DotNetBuildSourceOnly=true`) disable `FEATURE_WINDOWSINTEROP`. CI treats **all warnings as errors**. Run both builds before every push:

```shell
# Normal build
dotnet msbuild MSBuild.Dev.slnf -v:q

# Source-build — catches unused usings/members/docs from #if guards
dotnet msbuild MSBuild.SourceBuild.slnf /p:DotNetBuildSourceOnly=true -v:q
```

**Everything** only referenced inside `#if FEATURE_WINDOWSINTEROP` must also be guarded:
- **IDE0005**: `using` directives — most common failure
- **IDE0051/IDE0052**: Private members (methods, fields)
- **CS1587**: XML doc comments (move inside `#if`, not before)
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ Instructions for GitHub Copilot and other AI coding agents working with the MSBu

### Technology Stack
- .NET 10.0 and .NET Framework 4.7.2
- C# 13 features (especially collection expressions)
- C# 14 features (especially collection expressions)
- xUnit with Shouldly for testing
- Multi-platform support (Windows, Linux, macOS)

## General

* Performance is the top priority - minimize allocations, avoid LINQ in hot paths, use efficient algorithms.
* Always use the latest C# features, currently C# 13, especially collection expressions (`[]` over `new Type[]`).
* Always use the latest C# features, currently C# 14, especially collection expressions (`[]` over `new Type[]`).
* Match the style of surrounding code when making edits, but modernize aggressively for substantial changes.

## Code Review Instructions
Expand Down
9 changes: 9 additions & 0 deletions eng/dependabot/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@
<PackageVersion Update="Verify.XunitV3" Condition="'$(VerifyXunitV3Version)' != ''" Version="$(VerifyXunitV3Version)" />
</ItemGroup>

<!-- CsWin32 source generator for Windows interop (dev-time only) -->
<ItemGroup>
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
<PackageVersion Update="Microsoft.Windows.CsWin32" Condition="'$(MicrosoftWindowsCsWin32Version)' != ''" Version="$(MicrosoftWindowsCsWin32Version)" />

<PackageVersion Include="PolySharp" Version="1.15.0" />
<PackageVersion Update="PolySharp" Condition="'$(PolySharpVersion)' != ''" Version="$(PolySharpVersion)" />
</ItemGroup>

<!-- Roslyn analyzer authoring packages (used by ThreadSafeTaskAnalyzer) -->
<ItemGroup>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
Expand Down
40 changes: 28 additions & 12 deletions src/Build.UnitTests/BackEnd/TargetUpToDateChecker_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
using Microsoft.Build.Shared;
using Microsoft.Win32.SafeHandles;
using Xunit;
#if FEATURE_WINDOWSINTEROP
using Windows.Win32.Storage.FileSystem;
#endif

#nullable disable

Expand Down Expand Up @@ -960,7 +963,7 @@ private void IsAnyOutOfDateTestHelper(

[Fact(Skip = "Creating a symlink on Windows requires elevation.")]
[SkipOnPlatform(TestPlatforms.AnyUnix, "Windows-specific test")]
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("windows6.1")]
public void NewSymlinkOldDestinationIsUpToDate()
{
SimpleSymlinkInputCheck(symlinkWriteTime: New,
Expand All @@ -971,7 +974,7 @@ public void NewSymlinkOldDestinationIsUpToDate()

[Fact(Skip = "Creating a symlink on Windows requires elevation.")]
[SkipOnPlatform(TestPlatforms.AnyUnix, "Windows-specific test")]
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("windows6.1")]
public void OldSymlinkOldDestinationIsUpToDate()
{
SimpleSymlinkInputCheck(symlinkWriteTime: Old,
Expand All @@ -982,7 +985,7 @@ public void OldSymlinkOldDestinationIsUpToDate()

[Fact(Skip = "Creating a symlink on Windows requires elevation.")]
[SkipOnPlatform(TestPlatforms.AnyUnix, "Windows-specific test")]
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("windows6.1")]
public void OldSymlinkNewDestinationIsNotUpToDate()
{
SimpleSymlinkInputCheck(symlinkWriteTime: Old,
Expand All @@ -993,7 +996,7 @@ public void OldSymlinkNewDestinationIsNotUpToDate()

[Fact(Skip = "Creating a symlink on Windows requires elevation.")]
[SkipOnPlatform(TestPlatforms.AnyUnix, "Windows-specific test")]
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("windows6.1")]
public void NewSymlinkNewDestinationIsNotUpToDate()
{
SimpleSymlinkInputCheck(symlinkWriteTime: Middle,
Expand All @@ -1004,15 +1007,26 @@ public void NewSymlinkNewDestinationIsNotUpToDate()

[DllImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("windows6.1")]
private static extern bool CreateSymbolicLink(string lpSymlinkFileName, string lpTargetFileName, UInt32 dwFlags);

[DllImport("kernel32.dll", SetLastError = true)]
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("windows6.1")]
private static extern bool SetFileTime(SafeFileHandle hFile, ref long creationTime,
ref long lastAccessTime, ref long lastWriteTime);

[SupportedOSPlatform("windows")]
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateFileW")]
[SupportedOSPlatform("windows6.1")]
private static extern SafeFileHandle CreateFileForSymlink(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);

[SupportedOSPlatform("windows6.1")]
private void SimpleSymlinkInputCheck(DateTime symlinkWriteTime, DateTime targetWriteTime,
DateTime outputWriteTime, bool expectedOutOfDate)
{
Expand All @@ -1038,11 +1052,13 @@ private void SimpleSymlinkInputCheck(DateTime symlinkWriteTime, DateTime targetW

// File.SetLastWriteTime on the symlink sets the target write time,
// so set the symlink's write time the hard way
using (SafeFileHandle handle =
NativeMethodsShared.CreateFile(
inputSymlink, NativeMethodsShared.GENERIC_READ | 0x100 /* FILE_WRITE_ATTRIBUTES */,
NativeMethodsShared.FILE_SHARE_READ, IntPtr.Zero, NativeMethodsShared.OPEN_EXISTING,
NativeMethodsShared.FILE_ATTRIBUTE_NORMAL | NativeMethodsShared.FILE_FLAG_OPEN_REPARSE_POINT,
using (SafeFileHandle handle = CreateFileForSymlink(
inputSymlink,
(uint)FILE_ACCESS_RIGHTS.FILE_GENERIC_READ | 0x100 /* FILE_WRITE_ATTRIBUTES */,
(uint)FILE_SHARE_MODE.FILE_SHARE_READ,
IntPtr.Zero,
(uint)FILE_CREATION_DISPOSITION.OPEN_EXISTING,
(uint)(FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_NORMAL | FILE_FLAGS_AND_ATTRIBUTES.FILE_FLAG_OPEN_REPARSE_POINT),
IntPtr.Zero))
{
if (handle.IsInvalid)
Expand Down
16 changes: 10 additions & 6 deletions src/Build.UnitTests/ConsoleLogger_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
using Shouldly;
using Xunit;
using TaskItem = Microsoft.Build.Execution.ProjectItemInstance.TaskItem;
#if FEATURE_WINDOWSINTEROP
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Storage.FileSystem;
using Windows.Win32.System.Console;
#endif

#nullable disable

Expand Down Expand Up @@ -1959,18 +1965,16 @@ public void TestPrintTargetNamePerMessage()
/// Check to see what kind of device we are outputting the log to, is it a character device, a file, or something else
/// this can be used by loggers to modify their outputs based on the device they are writing to
/// </summary>
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("windows6.1")]
internal bool IsRunningWithCharacterFileType()
{
// Get the std out handle
IntPtr stdHandle = NativeMethodsShared.GetStdHandle(NativeMethodsShared.STD_OUTPUT_HANDLE);
HANDLE stdHandle = PInvoke.GetStdHandle(STD_HANDLE.STD_OUTPUT_HANDLE);

if (stdHandle != Microsoft.Build.BackEnd.NativeMethods.InvalidHandle)
if (stdHandle != HANDLE.INVALID_HANDLE_VALUE)
{
uint fileType = NativeMethodsShared.GetFileType(stdHandle);

// The std out is a char type(LPT or Console)
return fileType == NativeMethodsShared.FILE_TYPE_CHAR;
return PInvoke.GetFileType(stdHandle) == FILE_TYPE.FILE_TYPE_CHAR;
}
else
{
Expand Down
Loading
Loading