From 699e142e633ad64acb38bed8c6a591adce61dc61 Mon Sep 17 00:00:00 2001 From: Sebastien Lebreton Date: Fri, 3 Jul 2026 16:51:47 +0200 Subject: [PATCH 1/3] Restore 'scoped' on ref and ref-struct locals reassigned to a narrow 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) 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 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> --- .../TestCases/Pretty/RefFields.cs | 15 ++++- .../CSharp/Syntax/Modifiers.cs | 4 ++ .../CSharp/Transforms/DeclareVariables.cs | 59 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/RefFields.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/RefFields.cs index aff38438b7..e350b0761f 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/RefFields.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/RefFields.cs @@ -37,12 +37,23 @@ public void OutSpan(out Span span) public void Calls() { int value = 0; - Span span = CreateWithoutCapture(ref value); - //span = CreateAndCapture(ref value); -- would need scoped local, not yet implemented + scoped Span span = CreateWithoutCapture(ref value); + span = CreateAndCapture(ref value); span = ScopedRefSpan(ref span); span = ScopedSpan(span); OutSpan(out span); } + + public int ReassignScopedRefToLocal(bool b, ref int x) + { + int num = 42; + scoped ref int reference = ref x; + if (b) + { + reference = ref num; + } + return reference; + } } internal ref struct RefFields diff --git a/ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs b/ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs index d286f21cf8..ffa6501770 100644 --- a/ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs +++ b/ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs @@ -59,6 +59,7 @@ public enum Modifiers Async = 0x10000, Ref = 0x20000, Required = 0x40000, + Scoped = 0x80000, VisibilityMask = Private | Internal | Protected | Public, @@ -78,6 +79,7 @@ public static class CSharpModifiers Modifiers.Unsafe, Modifiers.Static, Modifiers.Abstract, Modifiers.Virtual, Modifiers.Sealed, Modifiers.Override, Modifiers.Required, Modifiers.Readonly, Modifiers.Volatile, + Modifiers.Scoped, Modifiers.Ref, Modifiers.Extern, Modifiers.Partial, Modifiers.Const, Modifiers.Async, @@ -124,6 +126,8 @@ public static string GetModifierName(Modifiers modifier) return "async"; case Modifiers.Ref: return "ref"; + case Modifiers.Scoped: + return "scoped"; case Modifiers.Required: return "required"; case Modifiers.Any: diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs b/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs index 8fbc4b9a32..5e8909a3a4 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs @@ -599,6 +599,58 @@ bool CombineDeclarationAndInitializer(VariableToDeclare v, TransformContext cont return !context.Settings.SeparateLocalVariableDeclarations; } + // A by-ref-like local (a 'ref' local or a ref struct such as Span) 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. + 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; + } + + // True if the stored ref / ref-struct value is limited to a method-local's scope: either the address + // of method-local storage (a narrow ref itself), 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). + static bool CapturesNarrowReference(ILInstruction value) + { + switch (value) + { + case LdLoca: + case LdFlda: + return IsAddressOfMethodLocal(value); + case CallInstruction call when call.OpCode is OpCode.Call or OpCode.CallVirt or OpCode.NewObj: + for (int i = 0; i < call.Arguments.Count; i++) + { + if (IsAddressOfMethodLocal(call.Arguments[i]) + && call.GetParameter(i) is { ReferenceKind: ReferenceKind.Ref or ReferenceKind.In or ReferenceKind.RefReadOnly } p + && !p.Lifetime.ScopedRef) + { + return true; + } + } + return false; + default: + return false; + } + } + + // True if 'addr' is the address of method-local storage: the address of a local/stack-slot, or a + // field thereof (a struct field of a local is exactly as narrow as the local). A field of a heap + // object (LdFlda over a loaded reference) or an array element bottoms out at a non-LdLoca root and + // is therefore treated as wide. + static bool IsAddressOfMethodLocal(ILInstruction addr) => addr switch { + LdLoca ldloca => ldloca.Variable.Kind is VariableKind.Local or VariableKind.StackSlot, + LdFlda ldflda => IsAddressOfMethodLocal(ldflda.Target), + _ => false, + }; + void InsertVariableDeclarations(TransformContext context) { var replacements = new List<(AstNode OldNode, Func CreateNewNode, string StepDescription)>(); @@ -638,6 +690,13 @@ void InsertVariableDeclarations(TransformContext context) init.AddAnnotation(annotation); } } + if (context.Settings.ScopedRef + && v.ILVariable.Kind is VariableKind.Local or VariableKind.StackSlot + && (type is ComposedType { HasRefSpecifier: true } || v.ILVariable.Type.IsByRefLike) + && RequiresScopedModifier(v.ILVariable)) + { + vds.Modifiers |= Modifiers.Scoped; + } return vds; }, "Combine variable declaration with initializer")); } From fc9dcbbe296025d905f05f391d5edc8b3f1d05bf Mon Sep 17 00:00:00 2001 From: Sebastien Lebreton Date: Sun, 12 Jul 2026 21:29:28 +0200 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../CSharp/Transforms/DeclareVariables.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs b/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs index 5e8909a3a4..74cc8fc69f 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs @@ -601,19 +601,19 @@ bool CombineDeclarationAndInitializer(VariableToDeclare v, TransformContext cont // A by-ref-like local (a 'ref' local or a ref struct such as Span) 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 + // ref-safety (otherwise CS8374 for ref locals, or CS8347/CS8352 for ref structs). '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. - 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; - } +static bool RequiresScopedModifier(ILVariable variable) +{ + if (variable.StoreInstructions.Count < 2) + return false; + foreach (var store in variable.StoreInstructions) + { + if (store is StLoc stloc && CapturesNarrowReference(stloc.Value)) + return true; + } + return false; +} // True if the stored ref / ref-struct value is limited to a method-local's scope: either the address // of method-local storage (a narrow ref itself), or a call/newobj that passes such an address to a From af78d4f0242ac4336f9a441b785ac799a4da7225 Mon Sep 17 00:00:00 2001 From: Sebastien Lebreton Date: Mon, 13 Jul 2026 00:02:40 +0200 Subject: [PATCH 3/3] Fix indentation of RequiresScopedModifier --- .../CSharp/Transforms/DeclareVariables.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs b/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs index 74cc8fc69f..2024d47a6e 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/DeclareVariables.cs @@ -601,19 +601,19 @@ bool CombineDeclarationAndInitializer(VariableToDeclare v, TransformContext cont // A by-ref-like local (a 'ref' local or a ref struct such as Span) 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 for ref locals, or CS8347/CS8352 for ref structs). 'scoped' has no runtime effect, and such a local provably + // ref-safety (otherwise CS8374 for ref locals, or CS8347/CS8352 for ref structs). '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. -static bool RequiresScopedModifier(ILVariable variable) -{ - if (variable.StoreInstructions.Count < 2) - return false; - foreach (var store in variable.StoreInstructions) - { - if (store is StLoc stloc && CapturesNarrowReference(stloc.Value)) - return true; - } - return false; -} + static bool RequiresScopedModifier(ILVariable variable) + { + if (variable.StoreInstructions.Count < 2) + return false; + foreach (var store in variable.StoreInstructions) + { + if (store is StLoc stloc && CapturesNarrowReference(stloc.Value)) + return true; + } + return false; + } // True if the stored ref / ref-struct value is limited to a method-local's scope: either the address // of method-local storage (a narrow ref itself), or a call/newobj that passes such an address to a