Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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