Skip to content

Support the updated memory safety rules (C# 15 / .NET 11) - #1819

Draft
Gérald Barré (meziantou) wants to merge 2 commits into
microsoft:mainfrom
meziantou:feature/dotnet-11-unsafe-model-bc4ba4
Draft

Support the updated memory safety rules (C# 15 / .NET 11)#1819
Gérald Barré (meziantou) wants to merge 2 commits into
microsoft:mainfrom
meziantou:feature/dotnet-11-unsafe-model-bc4ba4

Conversation

@meziantou

Copy link
Copy Markdown

What

Adds support for the updated memory safety model that is in preview in C# 15 / .NET 11, so that the generated projection compiles without new errors or warnings when a project opts in.

A project opts in with a compiler feature flag — there is no MSBuild property or public Roslyn API for it while the feature is in preview:

<LangVersion>preview</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Features>$(Features);updated-memory-safety-rules</Features>

When the flag is off, generated output is unchanged.

Why

The updated model splits unsafe into two roles: a contract that propagates an audit obligation to callers, and a block that scopes the operations which actually access unmanaged memory. Under those rules, the code CsWin32 emits today does not compile. Verified against the .NET 11 RC 1 compiler:

Error Rule What it hits in the projection
CS9377 unsafe on a type or delegate declaration unsafe partial struct, unsafe interface, unsafe delegate
CS9389 every extern / LibraryImport member must be unsafe or safe every P/Invoke, and the LocalExternFunction local functions
CS9392 every instance field of an explicitly laid out type must be marked every union
CS9360 the modifier on a signature no longer establishes a context for the body every generated body
CS9362 calling an unsafe member needs an unsafe context .NET 11 also marks System.Runtime.CompilerServices.Unsafe and MemoryMarshal.CreateSpan caller-unsafe, so members that were entirely safe before need a context now
CS9364 / CS9366 an unsafe member may not override or implement a safe one ToString, Equals, SafeHandle.ReleaseHandle

How

Generator.MemorySafetyRewriter is a post-processing pass over the final syntax trees, applied in GetCompilationUnits ahead of the whitespace rewriter. Keeping the rules in one place also means the hand-written templates/ are covered by the same logic as everything the generator composes.

The rules:

  1. Strip unsafe from type and delegate declarations, and from fields. Holding a pointer is a safe operation under the updated model, so a pointer-typed field needs no modifier — and leaving one off keeps the field readable and writable from safe code, exactly as it is today.
  2. Mark instance fields of explicitly laid out types (the unions) safe.
  3. Mark each extern-like member unsafe when its signature mentions a pointer, safe otherwise.
  4. For every other member, carry unsafe if and only if its signature mentions a pointer.
  5. Establish an unsafe context inside every member: an inner unsafe block for bodies (converting expression bodies to block bodies), and an unsafe(…) expression for the positions where a block cannot appear — field and property initializers, constructor-initializer arguments.

$(Features) is plumbed through CsWin32CodeGeneratorTask and the command line generator so the build-task path sees the same opt-in the source generator does.

Notes for reviewers

Three decisions are worth a look:

  • Rule 4 marks pointer-signature members unsafe, making them caller-unsafe. This matches the obligation the compiler already infers for a pointer-bearing signature under LangVersion=preview without the flag (CS9363), so a call site's obligations do not change when the flag is flipped. It also keeps the modifier off of overrides and interface implementations of safe members, whose signatures never mention pointers. The alternative — marking every extern safe — would be a false attestation for a pointer-taking P/Invoke.
  • Constructors are deliberately excluded from rule 4. Nothing can discharge a caller-unsafe obligation at a this(…)/base(…) initializer: the initializer call sits outside any block, and an unsafe(…) expression can only cover the arguments. A caller-unsafe constructor would be unreachable from a chained one. The projection's pointer-taking constructors only store the pointer, which the updated model treats as safe anyway.
  • Rule 5 applies to every member, not only the ones that were in an unsafe context before. Deciding which bodies truly need a context requires semantic information that isn't available while the code is being generated. A redundant unsafe block or expression produces no diagnostic, and because .NET 11 marks runtime members the projection depends on as caller-unsafe, members that were entirely safe under the original model need a context too. The cost is some extra nesting in generated code.

safe and unsafe(…) are emitted as an identifier token and an invocation respectively, because the Roslyn versions this generator builds against predate C# 15 and have no syntax nodes for them. Only the generated text matters — the C# 15 compiler that consumes it parses them correctly.

Verification

Generated with the flag set and compiled with the .NET 11 RC 1 compiler:

  • ~1.1M lines / 43 MB across roughly 30 namespaces and 6 module wildcards: zero compiler diagnostics, identical to the baseline generated without the flag.
  • Baseline-vs-flag comparison for: unmarshalled COM, public and internal projections, pointer overloads, preserveSig, an extension receiver, and net472 reference assemblies. Identical in every case.
  • Existing suite green: 839 tests in Microsoft.Windows.CsWin32.Tests and 245 in CsWin32Generator.Tests, plus the FullGeneration_Net9 regression test.
  • 12 new tests in UpdatedMemorySafetyRulesTests. They assert on generated text rather than compiling it, because the Roslyn version the test project builds against predates C# 15 and can neither parse safe / unsafe(…) nor enforce the rules.

Known limitation: the SDK's COM interop generator

With CsWin32's COM source generators enabled, the build still fails — but not because of what CsWin32 emits. The SDK's own Microsoft.Interop.ComInterfaceGenerator emits unsafe on the interfaces it generates, which CS9377 rejects, and the error surfaces on every partial declaration of those types, including the half CsWin32 emits. It also emits unsafe implementations of inherited members that resolve as safe (CS9366).

This reproduces with a hand-written [GeneratedComInterface] and no CsWin32 involved:

[GeneratedComInterface, Guid("00000000-0000-0000-C000-000000000046")]
internal partial interface IBase
{
    unsafe void GetThing(int** value);
}

[GeneratedComInterface, Guid("00000000-0000-0000-C000-000000000047")]
internal partial interface IDerived : IBase
{
    unsafe void GetOther(int** value);
}

so it has to be fixed in the SDK. Documented in getting-started.md along with the workaround: set comInterop.useComSourceGenerators (or allowMarshaling) to false when opting into the updated rules.

Not covered here

  • CsWin32Generator.BuildTasks.Tests targets net472, so the two new command-line assertions there compile but were not run in this environment.
  • Generated members that carry an attribute but no modifiers are mis-indented. This predates the change and reproduces in baseline output, so it is left for a separate fix.

C# 15 and .NET 11 preview an updated memory safety model in which `unsafe`
on a member is a contract that propagates an audit obligation to callers
rather than a marker that pointer syntax appears nearby. A project opts in
with the `updated-memory-safety-rules` compiler feature flag, and under
those rules the code we generate today no longer compiles: `unsafe` on a
type or delegate declaration is an error, every `extern` member and every
instance field of an explicitly laid out type must declare its safety, the
modifier on a signature no longer establishes an unsafe context for the
body, and calling an `unsafe` member requires a context at the call site.

Detect the feature flag in the parse options — the only signal available
while the feature is in preview, since it has neither an MSBuild property
nor a public Roslyn API — and adapt the emitted code accordingly. The
adaptation is a post-processing pass over the final syntax trees, which
keeps the rules in one place and covers the hand-written templates along
with everything the generator composes. When the flag is off, output is
unchanged.

A member carries `unsafe` if and only if its signature mentions a pointer.
That matches the obligation the compiler already infers for a
pointer-bearing signature under LangVersion=preview without the flag, so a
call site's obligations don't change with the opt-in, and it keeps the
modifier off of overrides and interface implementations of safe members
such as ToString and SafeHandle.ReleaseHandle, which the updated rules
would otherwise reject. Constructors are excluded: nothing can discharge a
caller-unsafe obligation at a `this(…)`/`base(…)` initializer, and the
projection's pointer-taking constructors only store the pointer, which the
updated model treats as safe.

Bodies get an inner `unsafe` block, and the positions where a block can't
appear — field and property initializers, constructor-initializer
arguments — get an `unsafe(…)` expression. This is applied to every member
rather than only the ones that were in an unsafe context before, because
deciding which bodies truly need a context requires semantic information
that isn't available while the code is being generated. A redundant block
or expression produces no diagnostic, and .NET 11 marks runtime members the
projection depends on (System.Runtime.CompilerServices.Unsafe,
MemoryMarshal.CreateSpan) as caller-unsafe, so members that were entirely
safe under the original model need a context now too.

Plumb the MSBuild $(Features) property through the build task and the
command line generator so the build-task path sees the same opt-in the
source generator does.

Verified by generating ~1.1M lines across roughly 30 namespaces and
compiling them with the .NET 11 RC 1 compiler with the flag set: zero
diagnostics, identical to the baseline without the flag. Also verified for
unmarshalled COM, public and internal projections, pointer overloads,
preserveSig, an extension receiver, and net472 references.

One combination still fails, and not from what we emit: the SDK's own COM
interop source generator emits `unsafe` on the interfaces it generates,
which the updated rules reject, and the error surfaces on every partial
declaration of those types including ours. This reproduces with a
hand-written [GeneratedComInterface] and no CsWin32 involved, so it has to
be fixed in the SDK. Documented, along with the workaround of turning off
comInterop.useComSourceGenerators.
CommandLineBuilder decides for itself whether a switch value needs quoting
— a semicolon-separated list gets quoted, a single feature name doesn't —
so asserting that the value appears verbatim was wrong. Match either form
instead: what the test is about is that the task forwards $(Features) to
the generator, not MSBuild's quoting rules.

The plumbing itself was correct. A quoted list arrives as a single
argument, and the generator splits it on semicolons.
@meziantou
Gérald Barré (meziantou) force-pushed the feature/dotnet-11-unsafe-model-bc4ba4 branch from 60a9869 to 322df84 Compare September 10, 2026 05:21
@jevansaks

Copy link
Copy Markdown
Member

Jeremy Koritzinsky (@jkoritzinsky) Aaron R Robinson (@AaronRobinsonMSFT) The call-out that COM source generators are broken with new safety rules, are you aware of this?

Gérald Barré (@meziantou) can you file an issue on https://github.com/dotnet/runtime ?

@meziantou

Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants