Skip to content

Fix #3853: infer and restore scoped on ref and ref-struct locals#3854

Open
sailro wants to merge 1 commit into
icsharpcode:masterfrom
sailro:scoped-local-inference
Open

Fix #3853: infer and restore scoped on ref and ref-struct locals#3854
sailro wants to merge 1 commit into
icsharpcode:masterfrom
sailro:scoped-local-inference

Conversation

@sailro

@sailro sailro commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #3853.

scoped on a local (scoped ref T, or a ref-struct local such as scoped Span<T>) has no runtime effect and — unlike scoped on a parameter (ScopedRefAttribute) — is not encoded in IL, metadata, or the PDB, so the decompiler dropped it. The output then failed to recompile whenever the local was reassigned a narrower-scoped value: CS8374 (ref locals) or CS8347 / CS8352 (ref structs).

Fix

DeclareVariables now infers scoped for the cases where it is determined by the local's use. A by-ref-like local (a ref local, or a ref struct — IType.IsByRefLike) that has more than one store and is assigned a value limited to a method-local's scope must have been declared scoped for the original source to pass ref-safety. Such a narrow value is:

  • the address of a method-local, or a field thereof (ldloca / ldflda rooted in a Local/StackSlot), or
  • a call/newobj that passes such an address to a non-scoped by-ref parameter — the returned ref struct captures it and inherits the narrow scope.

Passing a local's address to a scoped by-ref parameter does not capture it, so those calls correctly do not trigger the modifier (!IParameter.Lifetime.ScopedRef). The inference is gated on the existing ScopedRef setting and emits Modifiers.Scoped before the type.

Because scoped has no runtime effect and such a local provably cannot escape the method, restoring it is always behavior-preserving.

Before (does not recompile — CS8374 / CS8347):

ref int reference = ref x;
reference = ref num;
// ...
Span<int> span = NoCapture(ref v);
span = Capture(ref v);

After:

scoped ref int reference = ref x;
reference = ref num;
// ...
scoped Span<int> span = NoCapture(ref v);
span = Capture(ref v);

Scope

Only the determined cases above (the combined declaration = initializer form) are inferred. A scoped local declared separately from its assignment, a direct stackalloc reassignment, and receiver-captured returns are not covered and decompile as before. The rule is conservative — it never adds scoped unless the assigned value is provably narrow — and the full Pretty suite confirms no over-application across the existing corpus.

Test

Pretty/RefFields (roslyn4+): a new LifetimeTests.ReassignScopedRefToLocal (ref local) and the now-enabled scoped Span<T> case in LifetimeTests.Calls (which previously carried a // -- would need scoped local, not yet implemented note). Both fail without the fix (the modifier is dropped) and pass with it.

…reference

A local declared 'scoped' compiles to IL identical to a non-scoped one ('scoped'
is a compile-time ref-safety annotation with no runtime effect, and - unlike
'scoped' on parameters - it is not encoded in metadata or the PDB), so the
decompiler dropped it. That produces code which fails to recompile with CS8374
(ref locals) or CS8352/CS8347 (ref structs) whenever the local is later
reassigned a value with a narrower escape scope.

Infer and restore 'scoped' for the determined cases: a by-ref-like local (a
'ref' local or a ref struct such as Span<T>) that has more than one store and is
assigned a value limited to a method-local's scope, namely:

- the address of a method-local, or a field thereof (ldloca / ldflda rooted in a
  Local or StackSlot), or
- a call/newobj that passes such an address to a NON-scoped by-ref parameter (so
  the returned ref struct captures it and inherits the narrow scope).

Passing a local's address to a 'scoped' by-ref parameter does not capture it, so
those calls correctly do not trigger the modifier. Such a local provably cannot
escape the method, so it must have been declared 'scoped' for the original
source to pass ref-safety; restoring it is always behavior-preserving.

- Add Modifiers.Scoped (emitted before the type, gated on the ScopedRef setting).
- DeclareVariables: mark a qualifying local declaration scoped.
- Tests in RefFields (roslyn4+): LifetimeTests.ReassignScopedRefToLocal (ref
  local) and the now-enabled scoped Span<T> case in LifetimeTests.Calls; both
  fail without the fix and pass with it.

Only the combined 'declaration = initializer' form is handled; a scoped local
declared separately from its assignment, a direct 'stackalloc' reassignment, and
receiver-captured returns are not yet covered.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

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.

Pull request overview

This PR fixes ILSpy decompilation of scoped locals (scoped ref T and scoped ref-struct locals like Span<T>) by inferring when scoped is required for ref-safety and emitting it in the decompiled C# so the output recompiles.

Changes:

  • Infer and emit scoped on by-ref-like locals when ref-safety requires it (based on stores capturing method-local addresses).
  • Add Modifiers.Scoped to the C# AST modifier model and output ordering/name mapping.
  • Update Pretty test coverage to include scoped Span<T> reassignment and a scoped ref reassignment regression case.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs Adds inference logic to apply Modifiers.Scoped to certain ref/ref-struct locals during declaration+initializer combining.
ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs Introduces Modifiers.Scoped and ensures it prints as scoped in the correct modifier order.
ICSharpCode.Decompiler.Tests/TestCases/Pretty/RefFields.cs Extends Pretty test cases to exercise the new scoped local recovery behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +606 to +616
static bool RequiresScopedModifier(ILVariable v)
{
if (v.StoreInstructions.Count < 2)
return false;
foreach (var store in v.StoreInstructions)
{
if (store is StLoc stloc && CapturesNarrowReference(stloc.Value))
return true;
}
return false;
}
Comment on lines +602 to +605
// A by-ref-like local (a 'ref' local or a ref struct such as Span<T>) that is re-assigned to a value
// limited to a method-local's scope must have been declared 'scoped' for the original source to pass
// ref-safety (otherwise CS8374/CS8352). 'scoped' has no runtime effect, and such a local provably
// cannot escape the method, so restoring it is behavior-preserving and lets the code recompile.
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