Fix #3853: infer and restore scoped on ref and ref-struct locals#3854
Open
sailro wants to merge 1 commit into
Open
Fix #3853: infer and restore scoped on ref and ref-struct locals#3854sailro wants to merge 1 commit into
scoped on ref and ref-struct locals#3854sailro wants to merge 1 commit into
Conversation
…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>
aa6bfb3 to
699e142
Compare
Contributor
There was a problem hiding this comment.
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
scopedon by-ref-like locals when ref-safety requires it (based on stores capturing method-local addresses). - Add
Modifiers.Scopedto the C# AST modifier model and output ordering/name mapping. - Update Pretty test coverage to include
scoped Span<T>reassignment and ascoped refreassignment 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3853.
scopedon a local (scoped ref T, or a ref-struct local such asscoped Span<T>) has no runtime effect and — unlikescopedon 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
DeclareVariablesnow infersscopedfor the cases where it is determined by the local's use. A by-ref-like local (areflocal, 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 declaredscopedfor the original source to pass ref-safety. Such a narrow value is:ldloca/ldfldarooted in aLocal/StackSlot), orscopedby-ref parameter — the returned ref struct captures it and inherits the narrow scope.Passing a local's address to a
scopedby-ref parameter does not capture it, so those calls correctly do not trigger the modifier (!IParameter.Lifetime.ScopedRef). The inference is gated on the existingScopedRefsetting and emitsModifiers.Scopedbefore the type.Because
scopedhas no runtime effect and such a local provably cannot escape the method, restoring it is always behavior-preserving.Before (does not recompile — CS8374 / CS8347):
After:
Scope
Only the determined cases above (the combined
declaration = initializerform) are inferred. Ascopedlocal declared separately from its assignment, a directstackallocreassignment, and receiver-captured returns are not covered and decompile as before. The rule is conservative — it never addsscopedunless the assigned value is provably narrow — and the fullPrettysuite confirms no over-application across the existing corpus.Test
Pretty/RefFields(roslyn4+): a newLifetimeTests.ReassignScopedRefToLocal(ref local) and the now-enabledscoped Span<T>case inLifetimeTests.Calls(which previously carried a// -- would need scoped local, not yet implementednote). Both fail without the fix (the modifier is dropped) and pass with it.