CtrlK
BlogDocsLog inGet started
Tessl Logo

cswin32-com

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).

76

Quality

96%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

CsWin32 COM Interop Guide

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

Paired skill: cswin32-interop covers general P/Invoke and the blittable signature rules that apply to both [DllImport] and COM vtables. This file covers only the COM-specific layer.

Workflow

  1. Interface in Win32 metadata? Add the name to src/Framework/NativeMethods.txt → CsWin32 generates it. Not in metadata (WMI, Fusion, Setup Configuration) → define a manual struct under its own folder, excluded from source builds via <Compile Remove>.
  2. Lifetime: using ComScope<T> scope = new(); for every transient COM pointer (CoCreateInstance, QueryInterface, IEnumXxx::Next, factory output, app-local STDAPI Get*, etc.). Never write T* local; try { ... } finally { local->Release(); } — that's the pre-ComScope shape that leaks on every early return. Same for BSTR out-params: using BSTR x = default;.
  3. Activate via ComClassFactory.TryCreate(CLSID, ...) (AOT-compatible) or PInvoke.CoCreateInstance with IID.Get<T>() — not &localGuid.
  4. Call via scope.Pointer->Method(...). Pass ComScope<T> directly where the API expects T** / void** — the implicit operator does the address-of.
  5. Match the caller's error contract. If the top-level consumer swallows COM failure ("no result" == "absent"), helpers return default / false instead of throwing a COMException that's immediately discarded. ThrowOnFailure only when the exception will actually propagate or assert a should-never-happen.
  6. Guard with #if FEATURE_WINDOWSINTEROP — add && NET only when the struct uses the static-abstract IComIID form exclusively (no net472 dual-target).

Manual COM Structs (Not in Metadata)

Define each interface in its own file under e.g. src/Tasks/AssemblyDependency/Fusion/, src/Framework/Shared/VisualStudio/, src/Framework/Utilities/Wmi/. Exclude from source builds. Pattern (dual-target — works on net472 + .NET):

internal unsafe struct IAssemblyCache : IComIID
{
    public static readonly Guid IID_IAssemblyCache = new(0xE707DCDE, ...);

#if NET
    static ref readonly Guid IComIID.Guid
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => ref Unsafe.AsRef(in IID_IAssemblyCache);
    }
#else
    readonly ref readonly Guid IComIID.Guid
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => ref Unsafe.AsRef(in IID_IAssemblyCache);
    }
#endif

    private readonly void** _lpVtbl;

    // IUnknown at indices 0-2, then interface methods at 3+
    public HRESULT QueryInterface(Guid* riid, void** ppvObject)
    {
        fixed (IAssemblyCache* pThis = &this)
            return ((delegate* unmanaged[Stdcall]<IAssemblyCache*, Guid*, void**, HRESULT>)_lpVtbl[0])(pThis, riid, ppvObject);
    }
    // AddRef @1, Release @2, then interface methods at correct slot indices.
}

Rules:

  • delegate* unmanaged[Stdcall] for the function-pointer cast — IDL STDMETHODCALLTYPE__stdcall on Win32. Picking the wrong convention silently corrupts the stack.
  • IComIID has two shapes: static-abstract (static ref readonly Guid IComIID.Guid) on .NET 7+, instance (readonly ref readonly Guid IComIID.Guid) on net472 / netstandard2.0. CsWin32 picks the right one for its generated structs automatically (see IComIID across TFMs); a manual struct must spell out both arms itself. For .NET-only structs (WMI), drop the #else branch and gate the whole file #if NET. For dual-target structs (Fusion), keep both as shown — see src/Tasks/AssemblyDependency/Fusion/ for the canonical example.
  • Use CsWin32-generated PCWSTR / PWSTR for wide strings (add to NativeMethods.txt); raw char* only when no typed equivalent exists.
  • Vtable slots are exact: index 0 = QueryInterface, 1 = AddRef, 2 = Release, 3+ = interface methods in IDL order. When inheriting, add the parent interface's method count before counting your own (e.g. ISetupConfiguration2.EnumAllInstances is at slot 6 = 3 IUnknown + 3 v1 + 0).
  • CS0592 prevents [SupportedOSPlatform] on structs — put it on individual methods.

Lifetime & Access

A raw T* (COM struct) is forbidden as a field on a non-ref type. Allowed locations: locals in unsafe methods, parameters, fields of a ref struct. Anywhere else (instance fields of class / non-ref struct) → use AgileComPointer<T> — a raw pointer field is an apartment-agility hazard and leaks on finalize-without-Dispose.

ComScope<T> — transient pointers

ref struct, using'd, calls Release on dispose. The default for everything that doesn't survive the current method. Implicitly converts to T** and void**, so out-params write into the scope directly:

using ComScope<ISomeInterface> scope = new();
Guid clsid = SomeStruct.CLSID;
Guid iid = IID.Get<ISomeInterface>();
PInvoke.CoCreateInstance(&clsid, null, CLSCTX.CLSCTX_INPROC_SERVER, &iid, scope).ThrowOnFailure();
scope.Pointer->DoThing(...);

using ComScope<IOther> other = new();
Guid otherIid = IOther.IID_IOther;
scope.Pointer->QueryInterface(&otherIid, other).ThrowOnFailure();
  • Access methods via scope.Pointer->Method(...). Null-check with scope.IsNull.
  • Applies to every COM out-param: CoCreateInstance, QueryInterface, IEnumXxx::Next, factory methods, app-local STDAPI Get* (declare the [DllImport] with T** pp, not out T*, so the implicit operator binds).

Ownership transfer out of a helper. A helper that acquires a COM pointer can return ComScope<T> directly; intermediate pointers stay in their own using scopes and Release on the helper's return. default ComScope<T> is null and its Dispose is a no-op, so callers don't need an extra null guard:

private static ComScope<ISetupConfiguration2> AcquireSetupConfig()
{
    Guid clsid = SetupConfiguration.CLSID_SetupConfiguration;
    Guid iid = ISetupConfiguration2.IID_ISetupConfiguration2;

    ComScope<ISetupConfiguration2> config = new();
    HRESULT hr = PInvoke.CoCreateInstance(&clsid, null, CLSCTX.CLSCTX_INPROC_SERVER, &iid, config);
    if (hr.Succeeded) return config;                 // ownership flows to caller
    if (hr != HRESULT.REGDB_E_CLASSNOTREG) return default;

    using ComScope<ISetupConfiguration> v1 = new();  // intermediate, Released on return
    if (SetupConfiguration.GetSetupConfiguration(v1, IntPtr.Zero) < 0 || v1.IsNull) return default;
    return v1.Pointer->QueryInterface(&iid, config).Succeeded ? config : default;
}

// caller:
using ComScope<ISetupConfiguration2> config = AcquireSetupConfig();
if (!config.IsNull) { /* use config.Pointer */ }

BSTR — COM strings

CsWin32-generated; extended in Windows/Win32/Foundation/BSTR.cs with IDisposable. Dispose calls SysFreeString and zeroes the field in place. Scope every COM BSTR out-param with using BSTR x = default; — no manual try/finally + SysFreeString:

using BSTR versionBstr = default;
if (instance->GetInstallationVersion(&versionBstr).Failed) return false;
string version = versionBstr.ToString();
// SysFreeString runs at scope exit.

In-place-only: BSTR.Dispose writes through Unsafe.AsRef(in this), so using must be on the storage location, not on a method-returned copy. Same rule as ComScope<T>.

AgileComPointer<T> — fields outliving a method

Finalizable managed class. Use whenever a COM pointer is stored in a class field (the only legal way — raw T* fields are forbidden, see top of section).

  • Registers in the Global Interface Table (thread-agile); finalizer releases if Dispose is missed.
  • Access via using ComScope<T> scope = agile.GetInterface(); then scope.Pointer->Method(...). GetInterface() round-trips through the GIT — hoist a single scope to the top of a method when several calls share it.
  • Constructor takeOwnership: pass false when the raw pointer is also held by a ComScope<T> that will Release (GIT AddRefs independently); pass true only when no other code path will Release.
  • Dispose via the owner's Dispose / DisposeManagedResources (AgileComPointer is managed, not unmanaged — its finalizer is the safety net, not the primary cleanup).

Activation

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

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

Error-Handling Parity When Migrating

Struct-based COM returns raw HRESULT; [ComImport] and built-in activation threw automatically. Preserve the old throw-vs-return at each call site:

Old shapeThrew viaMigrated shape
new SomeCoClass()built-in activationPInvoke.CoCreateInstance(...).ThrowOnFailure()
(IFoo)rcw castInvalidCastException on QIQueryInterface(&iid, scope).ThrowOnFailure()
[ComImport] method, default PreserveSig=falsemarshaller throws on FAILED(hr).ThrowOnFailure() at the call site
[ComImport] method with [PreserveSig]caller inspects returnmirror the existing hr branch — don't start throwing

Factory-method exception: when the old contract was "return null for invalid input" (e.g. Create(path)), keep null-return only for the legitimate-rejection path; activation / QI failures still throw. See MetadataReader.cs (throws on activation/QI, returns null on OpenScope).

Don't throw when the caller swallows it. If the top-level consumer wraps the whole chain in catch (COMException) { }, inner helpers must return default / false / empty instead of constructing a COMException only to have it discarded. Throwing here allocates the exception, walks the stack, and hides the HRESULT for no benefit. See VisualStudioLocationHelper.AcquireSetupConfiguration2.

Mocking Struct-Based COM in Tests

Struct-based COM calls go through raw vtable pointers, so a managed mock can't be passed where a T* is expected. Bridge with the built-in COM marshaller: the mock implements the managed interface (BCL System.Runtime.InteropServices.ComTypes.* when ABI-compatible — true for ITypeLib / ITypeInfo — else CsWin32's nested [ComImport] internal interface Interface), and the test passes a CCW (COM-callable wrapper) pointer to the code under test:

// MockTypeLib : ComTypes.ITypeLib   (a normal managed class)
IntPtr ccw = Marshal.GetComInterfaceForObject(mock, typeof(ComTypes.ITypeLib));
try { walker.AnalyzeTypeLibrary((ITypeLib*)ccw); }   // struct API; real QueryInterface works
finally { Marshal.Release(ccw); }

The CCW exposes a real vtable, so no hand-rolled native vtable is needed and the managed mock framework stays intact. But the mock must now behave like real COM, not a managed object:

  • All [out] params are written, even ones the caller ignores. Passing null for a trailing out-param (GetDocumentation(-1, &name, null, null, null)) faults the marshaller — supply a real pointer for each.
  • Exception identity is lost. An HRESULT-returning method resurfaces a thrown exception as a new COMException (HRESULT preserved, instance not); a uint/void method with no HRESULT channel (GetTypeInfoCount, the Release* methods) swallows it silently and returns garbage. Assert on type/count, not the injected instance, and don't rely on void/uint methods to propagate.
  • Throw COMException on bad input, don't Assert. An Assert throws an XunitException that escapes the SUT's catch (COMException); a COMException becomes a failing HRESULT the SUT handles normally. (Matters because a swallowed GetTypeInfoCount failure can feed an out-of-range index to the next call.)

See MockTypeLib.cs / MockTypeInfo.cs and the CCW bridge in ComReferenceWalker_Tests.cs.

Strongly-typed handle / token wrappers

When the native side typedefs a primitive into a family of "same shape, different meaning" aliases (corhdr.h's typedef ULONG32 mdToken; typedef mdToken mdAssembly; ...), mirror that hierarchy with distinct readonly struct wrappers holding the single underlying primitive — same pattern as CsWin32's HANDLE / HWND / HMODULE. Stays blittable; delegate* casts and arrays marshal at zero cost.

Conversions follow the typedef hierarchy: implicit widening to the base (MdAssemblyMdToken, always safe), explicit narrowing ((MdAssembly)token, opt-in because the C side can't enforce the kind at the cast site).

Check the native header for the canonical validation primitives before defining IsNil / IsValid. Encoding details often hide in macros that don't grep cleanly. For mdToken the encoding is (TableType << 24) | Rid:

C macroMeaning
TypeFromToken(tk) = tk & 0xff000000Table-type tag (high byte) → CorTokenType
RidFromToken(tk) = tk & 0x00ffffffRow id (low 24 bits)
IsNilToken(tk) = RidFromToken(tk) == 0Nil check — rid half, not the whole value
mdAssemblyNil = mdtAssembly = 0x20000000Per-type nil is the table-type tag, not 0

A naive IsNil => Value == 0 would misclassify the 0x20000000 "no assembly in this scope" return from GetAssemblyFromScope. Mirror the macro: IsNil => Rid == 0. Reference: Tokens.cs, CorTokenType.cs.

IComIID across TFMs

CsWin32 (since 0.3.287) emits IComIID and attaches it to every generated COM struct on all target frameworks — static-abstract static ref readonly Guid Guid { get; } on .NET 7+, instance ref readonly Guid Guid { get; } on net472 / netstandard2.0. No hand-authored polyfill is needed; the old src/Framework/Polyfills/IComIID.cs + IComIIDPolyfills.cs files (one partial per generated struct) are gone.

When you add a new CsWin32-generated COM type to NativeMethods.txt, it carries IComIID automatically through ComScope<T> — nothing extra to do.

Only manual structs (WMI, Fusion, Setup Configuration, CLR metadata) implement IComIID by hand, matching the per-TFM shape — see Manual COM Structs. IID.Get<T>() (IID.cs) reads it via T.Guid on .NET and default(T).Guid on net472.

File Organization

LocationContents
src/Framework/Windows/Win32/System/Com/ComScope, ComClassFactory, AgileComPointer, GlobalInterfaceTable
src/Framework/Windows/Win32/IID.csGeneric IID lookup
src/Framework/Utilities/Wmi/, src/Framework/Shared/VisualStudio/, src/Tasks/AssemblyDependency/Fusion/, src/Tasks/AssemblyDependency/Metadata/, src/Tasks/TypeLib/Manual COM struct interfaces — own folder per API surface

CS3016 CLS Compliance

CsWin32 (since 0.3.287) auto-emits [CLSCompliant(false)] on every generated COM struct wrapper that carries CCW thunks (the [UnmanagedCallersOnly(CallConvs = new[] { ... })] array argument is what trips CS3016), and adds CS3019 / CS3021 to its own generated-file #pragma warning disable list. So a CLS-compliant assembly ([assembly: CLSCompliant(true)]) builds clean with no consumer action — the hand-authored GeneratedInteropClsCompliance.cs partial and the .editorconfig CS3019 suppression for {**/Windows/**/*.cs} are gone.

The attribute is only emitted on .NET 5+ TFMs (net472 / netstandard2.0 have no CCW thunks, so no CS3016) and only for internal wrappers (the generator default). Don't re-add per-type [CLSCompliant(false)] partials or blanket NoWarn. See https://github.com/dotnet/roslyn/issues/68526 for the underlying Roslyn limitation that motivated the generator-side fix.

Repository
dotnet/msbuild
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.