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, 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 replaces [DllImport] with source-generated PInvoke.* calls. FEATURE_WINDOWSINTEROP is the compile-time gate; source builds disable it.
Paired skill: cswin32-com covers struct-based COM interop on top of CsWin32 (ComScope<T>, AgileComPointer<T>, delegate* unmanaged vtables, IComIID, CoCreateInstance, manual COM structs not in Win32 metadata). This file covers only the general P/Invoke layer; the COM skill builds on its blittable-signature rules.
Rules
- Replace
[DllImport]withPInvoke.*. Delete old declarations and hand-written structs/enums/constants. - Gate with
#if FEATURE_WINDOWSINTEROP, add runtimeIsWindowscheck inside. Both required. - Use CsWin32 types directly (
HANDLE,HMODULE,HRESULT.S_OK,FILE_FLAGS_AND_ATTRIBUTES, etc.). - Call
PInvoke.*directly — no wrappers. Types flow viaInternalsVisibleTo. - Prefer CsWin32 for Windows APIs. Use
[LibraryImport]only for non-Windows native calls (e.g.libc), guarded with#if NET. - Preserve the old error-handling contract. Check the original
[DllImport]forPreserveSig/SetLastError/BOOL/HRESULTsemantics and reproduce them:PreserveSig=false→.ThrowOnFailure();SetLastError=true+ failedBOOL→throw new Win32Exception(). Silently returning where the old code threw is a behavior change. See cswin32-com's parity table for the COM-side equivalent.
Blittable signatures
CsWin32 is configured with allowMarshaling: false, so every [DllImport] and every manual
COM vtable method must be blittable — no marshalling at the boundary. These rules apply
to both. For COM-vtable-only additions, see
cswin32-com.
- Return
HRESULTfrom HRESULT-returning APIs (notint). Blittable (singleintfield), exposes.Succeeded/.Failed/.ThrowOnFailure(). UseHRESULT.S_OKover0; caste.HResultto(HRESULT)when wrapping.AddRef/Releasereturnuint. - Call
.ThrowOnFailure()instead ofif (hr.Failed) Marshal.ThrowExceptionForHR(hr)— same exception, IErrorInfo-enriched, one-line call site:iface->Method(...).ThrowOnFailure();. Branch onhronly when handling a specific HRESULT (e.g.ERROR_INSUFFICIENT_BUFFER) before throwing. See cswin32-com's parity table for the migration contract. - Use
PCWSTR/PWSTRfor wide strings, never managedstring. Implicit conversion fromfixed (char* p = managedString). Add toNativeMethods.txtif not yet generated. T**notout T*for pointer outputs.outtriggers marshaling + afixedround-trip at every call site.void*for opaque / reserved params — neverIntPtr.Zero; passnullliterally.IntPtris fine at boundaries with the wider .NET surface (Marshal.*,SafeHandle.DangerousGetHandle, public API).- Prefer
nint/nuintoverIntPtr/UIntPtrfor native-sized integers — no boxing, better cast semantics, noIntPtr.Zeroceremony. - No managed reference types (
string,StringBuilder, arrays) in blittable signatures. - Don't specify
PreserveSig = trueon[DllImport]— it's the default. UsePreserveSig = falseonly to force marshaller throw-on-failure (rare; prefer returningHRESULTand.ThrowOnFailure()).[ComImport]defaults the opposite way, but struct-based COM uses rawdelegate*and isn't affected — see cswin32-com. - Constrain flag / option parameters to a typed
enum. When a nativeDWORD/ULONG/intis documented as atypedef enumor#defineset, declare a C#[Flags] enum Foo : uint(matching the underlying type) and use it in the signature (anddelegate*cast for COM). Mirror the constraint even when the native side has no named enum. Self-documenting at the call site:OpenScope(path, CorOpenFlags.ofRead, ...)vsOpenScope(path, 0, ...). Co-locate the enum next to its consumer. SeeCorOpenFlags.csandCorAssemblyFlags.cs.
Dual Guard Pattern
#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 |
Manual COM structs gated .NET-only (e.g. WMI). CsWin32-generated COM types via ComScope<T> work on net472 too — the generator emits IComIID on every TFM (see cswin32-com) |
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).
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/.
Always prefer the generated enum over a local copy. Before defining private const int ERROR_* / private enum FooFlags, grep the CsWin32 metadata:
ERROR_*(Win32 error codes) →WIN32_ERROR.ERROR_*(uint enum)HRESULTcodes →HRESULT.S_OK, etc.- Restart Manager status/type →
RM_APP_STATUS/RM_APP_TYPE - File flags →
FILE_FLAGS_AND_ATTRIBUTES,FILE_ACCESS_RIGHTS,FILE_SHARE_MODE,FILE_CREATION_DISPOSITION - Process flags →
PROCESS_CREATION_FLAGS,STARTUPINFOW_FLAGS,PROCESS_ACCESS_RIGHTS - Memory mapping →
PAGE_PROTECTION_FLAGS,FILE_MAP - Shell folder →
KNOWN_FOLDER_FLAG
Cast int/uint return codes via (WIN32_ERROR)res for switch and equality. Add the enum to NativeMethods.txt if not yet generated, then check obj/.../generated/Microsoft.Windows.CsWin32/.../Windows.Win32.<EnumName>.g.cs.
Some flag values are standalone constants, not enum members. A Win32 #define outside a typedef enum generates as an internal const on PInvoke. Example: the LoadTypeLibEx flags are PInvoke.LOAD_TLB_AS_32BIT / _64BIT (uint), not members of REGKIND (which has only REGKIND_DEFAULT/REGISTER/NONE). Add the constant name to NativeMethods.txt like any API. OR it onto an enum at the constant's width and cast back: (REGKIND)((uint)REGKIND.REGKIND_NONE | PInvoke.LOAD_TLB_AS_32BIT). Don't reintroduce a local const CsWin32 already emits.
Match local types to the CsWin32 type. Instead of int res = (int)PInvoke.RmStartSession(...) and casting at every comparison, declare WIN32_ERROR res = PInvoke.RmStartSession(...) and let helpers like GetException(WIN32_ERROR res, ...) take the typed value. Cast to int/uint only at the boundary where a non-CsWin32 API needs it (e.g. new Win32Exception((int)res, ...)). The same applies to HRESULT, BOOL, HANDLE, PROCESS_CREATION_FLAGS, etc.
Delete local mirror enums that exist solely to mirror the Win32 one. The generated CsWin32 type is the source of truth.
FILETIME Conversions
Use the helpers in src/Framework/Windows/Win32/Foundation/FileTimeExtensions.cs:
fileTime.ToLong()→ 64-bit ticksfileTime.ToDateTime()→ localDateTime(FILETIME values returned as local time, e.g.RM_PROCESS_INFO.ProcessStartTime)fileTime.ToDateTimeUtc()→ UTCDateTime(FILETIME values returned as UTC, e.g.WIN32_FILE_ATTRIBUTE_DATA.ftLastWriteTime)
Do not hand-roll DateTime.FromFileTime((long)hi << 32 | lo) — use the helpers for consistency. Note CsWin32-generated structs use ComTypes.FILETIME (int fields) for COM members and Windows.Win32.Foundation.FILETIME (uint fields) for kernel ones; the extension covers ComTypes.FILETIME.
BufferScope
BufferScope<T> (src/Framework/Utilities/BufferScope.cs) — stackalloc initial buffer with ArrayPool<T> fallback. Lives in Framework, available to all projects via InternalsVisibleTo.
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 withusing. Never stack-allocate more than 1024 bytes.- Check CsWin32 convenience overloads (e.g.
GetShortPathName(string, Span<char>)) before writingfixedblocks.
Gotchas
CA1416 Platform Compatibility
No blanket NoWarn — handle semantically:
if (IsWindows)satisfies[SupportedOSPlatform]— no pragma neededif (IsUnixLike)satisfies[UnsupportedOSPlatform("windows")]- Never use
!IsWindows— useelse if (IsUnixLike). Seedocumentation/specs/CA1416-analyzer-analysis.md - Use versioned
[SupportedOSPlatform("windows6.1")]on methods calling CsWin32 APIs #pragma warning disable CA1416only for static local functions (analyzer limitation)- CS0592 prevents
[SupportedOSPlatform]onpartial struct— put on individual members instead
Type Conversions
HANDLE ↔ IntPtr:(HANDLE)intPtr/(IntPtr)h.Value. Sentinels:HANDLE.Null,HANDLE.INVALID_HANDLE_VALUEFILETIMEconversion:data.ftLastWriteTime.ToLong(),.ToDateTime()(local),.ToDateTimeUtc()— see "FILETIME Conversions" above. CsWin32 usesComTypes.FILETIME(int fields), notWin32.Foundation.FILETIMESafeFileHandle: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 inobj/
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:
# 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:
usingdirectives — most common failure - IDE0051/IDE0052: Private members (methods, fields, including helpers like
StringToByteArray, constants likeERROR_SHARING_VIOLATION) - CA1823: Unused private fields (e.g. constants only consumed inside the guard)
- CS1587: XML doc comments (move inside
#if, not before)
The same applies when adding polyfills in src/Framework/Polyfills/ (e.g. SpanExtensions, IndexOfAnyExcept): polyfills usually live behind #if !NET (or similar TFM guards) but are still consumed from #if FEATURE_WINDOWSINTEROP code paths. Always run the source-build to confirm the polyfill, its callers, and any helper members compile cleanly when interop is disabled — a polyfill referenced only by Windows-only code will trip IDE0051/CA1823 in source-only builds.