Skip to content

Add a code fixer that introduces inner unsafe contexts for unsafe-v2 diagnostics - #135341

Open
EgorBo wants to merge 3 commits into
dotnet:mainfrom
EgorBo:unsafe-context-codefix
Open

EgorBo wants to merge 3 commits into
dotnet:mainfrom
EgorBo:unsafe-context-codefix

Conversation

@EgorBo

@EgorBo EgorBo commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Adds UnsafeContextCodeFixProvider to ILLink.CodeFixProvider. Under the updated memory-safety rules (unsafe evolution), it fixes these Roslyn errors:

Code Text
CS9360 This operation may only be used in an unsafe context
CS9361 stackalloc expression without an initializer inside SkipLocalsInit may only be used in an unsafe context
CS9362 '{0}' must be used in an unsafe context because it is marked as 'unsafe'
CS9363 '{0}' must be used in an unsafe context because it has pointers in its signature
CS9376 An unsafe context is required for constructor '{0}' marked as 'unsafe' to satisfy the 'new()' constraint of type parameter '{1}' in '{2}'

It fixes each one by introducing an inner unsafe context around the operation. That context is either an unsafe { } block or an unsafe(...) expression, and each one carries a placeholder audit marker. The fixer never changes modifiers, signatures or project options - this is a job for fixers that run before it, see #131451

It replaces the incomplete RequiresUnsafeCodeFixProvider, which is deleted along with its tests.

Output

// Block: declarations that are used after the block are split out first.
int a, b;
// SAFETY: To be audited
unsafe
{
    a = *p;
    b = a + *p;
}
return a + b;

// Expression: used when a block is invalid or disproportionate (headers, expression bodies, initializers).
else if (/* SAFETY: To be audited */ unsafe(*p) == 0)
int M(int* p, int? cached, bool fast) => cached ?? (fast ? 0 : /* SAFETY: To be audited */ unsafe(Read(p)) + 1);

Code actions

Title Policy
Wrap in unsafe block Blocks first, falling back to unsafe(...) (default; used by Fix All and dotnet format)
Wrap expression in unsafe context unsafe(...) first, falling back to blocks
Wrap entire body in unsafe One block around the whole body, falling back to the default plan

How a context is chosen

  • Members are planned as a whole. Every fixable operation in a member is planned together, so operations share contexts however the fix was triggered.
  • Merging. Statements that own operations are merged into one block when they are adjacent, or when the statements in between keep the block proportionate. Proportionate means it covers no more unrelated statements than related ones.
  • Compound statements.
    • Operations only in a body (if, loops, switch, try, ...) are fixed inside that body.
    • Operations only in a header become unsafe(...) in that header.
  • Expression wrappers. Each statement, switch arm, case guard or catch filter gets at most one wrapper. It is the narrowest expression that covers all of that clause's operations. Expression bodies keep their form; one is converted to a block body only when no wrapper is valid.
  • Declarations. Locals that are used after a block are split out:
    • The exact type is kept. A var local is split only when its type can be written in source and doesn't depend on #if or a using alias.
    • Span locals initialized with stackalloc become scoped.
    • out variables and deconstructions are lifted.
    • using declarations extend the block to the end of the enclosing scope.
    • A leading label stays outside the block.

Safety

  • Contexts are never nested. A context never encloses a lambda, anonymous method or local function, because their bodies would silently become unsafe. It also never encloses yield, CallerArgumentExpression-captured arguments, or an #if group that is cut in half.
  • Inactive #if arms are scanned. Their text is scanned so that a block does not hide a declaration from, or move a declaration away from, code in another configuration.
  • Every candidate is checked by recompiling. The member is re-bound on a copy of the compilation. Its error and warning diagnostics must be unchanged, apart from the fixed ones disappearing: nothing new appears, and no existing diagnostic disappears or changes severity. A failing region moves to its next candidate. If no candidate passes, it is left unchanged.

Multiple passes and build configurations

The fixer can be run once per configuration (for example x64 Checked and then arm64 Release).

  • Contexts with the exact placeholder marker are treated as placeholders. Later passes extend or merge them instead of adding sibling or nested contexts.
  • Contexts with any other SAFETY: text are treated as audited and are left untouched.
  • Running the fixer again over its own output changes nothing.

Known limitations

In System.Private.CoreLib, 8 diagnostics are left unfixed. They fall into three patterns, and each needs a manual change.

1. Calls to unsafe constructors through : base(...) / : this(...) (3 occurrences)

A constructor initializer is neither a statement nor an expression, so no unsafe context can enclose the call. The only fix is to change the safety of one of the constructors, and the fixer never changes modifiers. This is a job for a SynchronizeUnsafeContractCodeFixProvider so by the time we run this analyzer we shouldn't see such patterns.

2. An unsafe call that takes a lambda argument (1 occurrence)

Both a block and unsafe(...) would enclose the lambda, which would make its body unsafe without an audit. Not a big deal, I guess. Manual fix: move the lambda into a local first, then wrap the call.

3. A void expression body that contains #if (4 occurrences)

  • The body of a void method must be a statement expression, so unsafe(...) cannot wrap the whole call.
  • Wrapping the two Cast calls separately would put two wrappers in one statement, and a statement gets at most one.
  • Converting the member to a block body is declined because the inactive #if arm is not parsed and would not carry over.
// BinaryPrimitives.ReverseEndianness.cs: MemoryMarshal.Cast is `unsafe` (the nuint and nint overloads, 2 calls each)
public static void ReverseEndianness(ReadOnlySpan<nuint> source, Span<nuint> destination) =>
#if TARGET_64BIT
    ReverseEndianness<long, Int64EndiannessReverser>(MemoryMarshal.Cast<nuint, long>(source), MemoryMarshal.Cast<nuint, long>(destination));
#else
    ReverseEndianness<int, Int32EndiannessReverser>(MemoryMarshal.Cast<nuint, int>(source), MemoryMarshal.Cast<nuint, int>(destination));
#endif

The ulong overload has the same shape without #if, and it is fixed by converting it to a block body. Manual fix: convert these two members to block bodies with the #if inside.

Running it on CoreLib

.\dotnet.cmd build src\tools\illink\src\ILLink.CodeFix\ILLink.CodeFixProvider.csproj -c Checked
$t = "$env:TEMP\unsafe-v2.targets"
Set-Content $t '<Project><PropertyGroup><Features>$(Features);updated-memory-safety-rules</Features></PropertyGroup></Project>'
cmd /c "set Configuration=Checked&& set CustomAfterMicrosoftCommonTargets=$t&& .\dotnet.cmd format analyzers src\coreclr\System.Private.CoreLib\System.Private.CoreLib.csproj --diagnostics CS9360 CS9361 CS9362 CS9363 CS9376 --severity info"

The root Directory.Build.props sets Features without appending to it, so the v2 feature flag has to be injected through CustomAfterMicrosoftCommonTargets.

…diagnostics

Adds UnsafeContextCodeFixProvider, which fixes CS9360, CS9361, CS9362, CS9363
and CS9376 under the updated memory-safety rules by introducing audited inner
contexts: `unsafe { }` blocks (preceded by `// SAFETY: To be audited`) or
`/* SAFETY: To be audited */ unsafe(...)` expressions. Every candidate is
validated by rebinding the member; members without a valid candidate are left
unchanged.

Replaces the incomplete RequiresUnsafeCodeFixProvider.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfef4956-ea1a-4a4a-926d-9ae912bb0e2f
@EgorBo
EgorBo requested a review from sbomer as a code owner October 7, 2026 15:30
@dotnet-policy-service dotnet-policy-service Bot added the linkable-framework Issues associated with delivering a linker friendly framework label Oct 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@github-actions github-actions Bot added the area-Tools-ILLink .NET linker development as well as trimming analyzers label Oct 7, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/illink
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Constructor-initializer capture, cross-configuration inference, trivia preservation, and candidate-exhaustion issues remain unresolved.

4 open findings
What changed in this PR

Adds a non-shipping ILLink code fixer that introduces narrowly scoped, audited unsafe contexts for unsafe-v2 compiler diagnostics.

Changes:

  • Replaces the legacy unsafe-modifier fixer with block/expression planning.
  • Validates edits through recompilation and supports multi-pass merging.
  • Adds coverage for statements, declarations, CoreLib patterns, and code actions.
File Description
UnsafeContextCodeFixTests.Statements.cs Tests statement transformations.
UnsafeContextCodeFixTests.MultiPass.cs Tests repeated/configuration passes.
UnsafeContextCodeFixTests.Declarations.cs Tests declaration splitting.
UnsafeContextCodeFixTests.cs Provides test infrastructure and core cases.
UnsafeContextCodeFixTests.CoreLibPatterns.cs Tests representative CoreLib patterns.
UnsafeContextCodeFixTests.Actions.cs Tests action policies and unsupported cases.
UnsafeTarget.cs Maps diagnostics to operations and scopes.
UnsafeContextValidator.cs Recompiles candidates and compares diagnostics.
UnsafeContextRegion.cs Models candidate regions and refinements.
UnsafeContextPlanner.cs Plans block and expression contexts.
UnsafeContextFormatting.cs Preserves layout and indentation.
UnsafeContextFacts.cs Defines diagnostics, markers, and feature detection.
UnsafeContextEdit.cs Implements expression and body edits.
UnsafeContextDocumentFixer.cs Coordinates planning and validation.
UnsafeContextCodeFixProvider.cs Registers actions and Fix All.
SyntaxLayoutExtensions.cs Adds syntax-layout helpers.
StatementShape.cs Models compound-statement structure.
StatementList.cs Models statement scopes and target spans.
StatementBlockEdit.cs Generates unsafe statement blocks.
PlanningContext.cs Holds semantic planning state.
PlaceholderContexts.cs Recognizes and merges placeholder contexts.
ExpressionWrapPlanner.cs Plans unsafe(...) wrappers.
DirectiveFacts.cs Handles conditional-compilation boundaries.
DeclarationSplitter.cs Hoists declarations safely around blocks.
BlockEditFactory.cs Builds and closes block ranges.
Resources.resx Adds localized action titles.
RequiresUnsafeCodeFixProvider.cs Removes the legacy fixer.
ILLink.CodeFixProvider.csproj Configures non-Release fixer compilation.

🧠 Review effort: Balanced

Comment thread src/tools/illink/src/ILLink.CodeFix/UnsafeContext/DeclarationSplitter.cs Outdated
Comment thread src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextDocumentFixer.cs Outdated
Comment thread src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementBlockEdit.cs Outdated
- Don't split `var` locals whose initializer contains preprocessor directives;
  the inferred type may differ in another configuration. Deconstruction
  declarations now pass their source to the same check.
- Keep inline comments of split declarations (e.g. `/* note */ int a = ...;`).
- Remove the validation round cap: it could drop a region before its later
  candidates were tried. Convergence is guaranteed because each failure
  advances a region to a later candidate.
- Add a regression test for CallerArgumentExpression-captured arguments in
  `: base(...)` / `: this(...)` initializers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfef4956-ea1a-4a4a-926d-9ae912bb0e2f
…ests

- Keep the trailing trivia of the declared identifier in the assignment, e.g.
  `value /* identifier */ = /* assignment */ Get();`.
- Keep comments after the type in hoisted declarations, e.g.
  `int /* explanation */ value;` for `Get(out int /* explanation */ value)`.
- Test that blocks containing `await` are introduced (allowed under the
  updated rules) and that a method-group conversion falls back to a getter.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfef4956-ea1a-4a4a-926d-9ae912bb0e2f

This branch has not been deployed

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

Labels

area-Tools-ILLink .NET linker development as well as trimming analyzers linkable-framework Issues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants