Skip to content

Fix #3853: infer scoped locals from ref safety#3888

Draft
sailro wants to merge 2 commits into
icsharpcode:masterfrom
sailro:scoped-local-inference-v2
Draft

Fix #3853: infer scoped locals from ref safety#3888
sailro wants to merge 2 commits into
icsharpcode:masterfrom
sailro:scoped-local-inference-v2

Conversation

@sailro

@sailro sailro commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #3853. Supersedes #3854. Depends on #3887.

Note

This is intentionally opened as a draft and is stacked on #3887. The current Files changed view also includes the foundation commit. Once #3887 merges, I will rebase this branch so this PR contains only the local-inference change.

tl;dr: scoped on a local is erased from IL and PDBs, so type-system support cannot recover
the modifier directly. This PR adds a late ILAst ref-safety analysis that emits scoped only
when a later assignment or mutation is strictly narrower than the declaration initializer.

Problem

A local's scoped modifier has no metadata representation. Dropping it produces invalid C#
when a wide initializer is followed by a narrower operation:

  • ref reassignment to method-local storage, CS8374
  • a ref-struct result that captures a local/ref/out argument, CS8347/CS8352/CS9077
  • stackalloc, CS8353
  • propagation through nested calls or other ref-struct locals
  • storing a narrow value into a ref-struct field
  • passing a narrow value to a method that may capture it into a writable ref-struct receiver

A shape-only check is not sufficient. It can both miss required modifiers and add redundant
ones when the initializer is already narrow.

Solution

Add IntroduceScopedModifierOnLocals, a late IILTransform that mirrors Roslyn's ref-safety
model:

  • track separate ref-escape and value-escape contexts
  • use the ordered contexts current method, return-only, and calling method
  • infer each local's contexts from its declaration initializer
  • iterate to a fixpoint for local-to-local propagation
  • apply Roslyn's updated and legacy invocation argument filtering
  • account for scoped and unscoped parameters, out, struct receivers, ref-to-ref-struct
    returns, fields, conditionals, null coalescing, and unknown signatures
  • account for later StLoc assignments, field stores, and captures into writable instance or
    classic ref extension receivers
  • handle bare declarations separately from declarations combined with an initializer
  • treat unknown shapes conservatively, so they cannot cause a false scoped

The transform records the result on ILVariable.IsScoped. DeclareVariables only renders the
modifier, following the existing IntroduceRefReadOnlyModifierOnLocals pattern.

Tests

The Pretty/RefFields Roslyn 4+ matrix covers:

  • ref-local and ref-struct positive cases
  • local, ref-parameter, out-parameter, receiver, stackalloc, nested-call, and local-copy
    propagation
  • scoped-ref ref-struct value propagation
  • field stores and captures into instance/ref-extension receivers
  • class, readonly, in/ref-readonly, readonly-ref-struct, and plain-struct receivers that must
    not cause scoped
  • narrow initializer followed by narrow or wider stores, which must not over-apply

Validation:

  • Debug and Release solution builds: 0 warnings, 0 errors
  • full Pretty suite: 1906 passed, 5 existing skips
  • full solution test suite: 4887 passed, 15 existing skips

Review focus

The main review area is the transfer logic in IntroduceScopedModifierOnLocals, especially
invocation escape and receiver-capture rules.

  • At least one test covering the code changed

sailro and others added 2 commits July 19, 2026 03:12
ScopedRefAttribute only records explicit syntax. Effective lifetime also
depends on UnscopedRefAttribute, params collections, out parameters, and
the defining module's RefSafetyRules version. Model those distinctions in
the type system without changing decompiler output.

Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 86d2918e-5a24-48b4-9a86-41d331ec3720
Local scopedness is erased from IL and PDBs, so it can only be recovered
from the body. Compare each declaration initializer with later assignments,
field stores, and receiver captures using the C# 11 ref/value escape rules,
and emit scoped only when a later operation is strictly narrower.

Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 86d2918e-5a24-48b4-9a86-41d331ec3720
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.

Decompiling a scoped ref local or ref-struct local drops the modifier (does not recompile)

2 participants