Fix #3803: Crash on nested deconstruction of custom structs - #3949
Conversation
773733f to
555e364
Compare
Code reviewWhat this doesTwo independent fixes plus regression coverage:
VerificationBeyond reading the diff, I reproduced the before/after independently (macOS,
Ordering logic checks out: a forwarding assignment is itself The delayed-action chain is safe: the forwarding actions are appended last in FindingsAll minor - nothing blocking. Comments left at the relevant lines.
ConventionsCommit message, |
| if (!deconstructionResultsLookup.TryGetValue(v, out int index)) | ||
| return; |
There was a problem hiding this comment.
Minor: this bail-out cannot actually protect anything. Delayed actions run from TransformDeconstruction after context.Step, once replacement is already built and about to be written into the block - so returning here leaves the pattern variable's load outside the DeconstructInstruction, i.e. exactly the malformed shape this PR fixes, just without the transform having declined it.
In practice it is unreachable: MatchDeconstruction registers every element of deconstructionResults in the lookup before returning, and the tuple path is filtered out earlier by the LoadCount check. Given that, a Debug.Assert (or dropping the guard entirely) states the invariant instead of quietly swallowing a violation of it.
| if (v?.LoadCount != 1) | ||
| continue; |
There was a problem hiding this comment.
Worth a line of comment here, because two non-obvious things have to line up for the tuple case to stay correct.
LoadCount is read eagerly, at match time, while the decision is applied later from the delayed action. For a tuple deconstruction the elements are the fresh E_i variables created in FindIndex, and their loads only come into existence when the delayed inst.ReplaceWith(new LdLoc(...)) runs - so LoadCount is still 0 here and forwarding never fires on that path. Same as before this PR, so no behaviour change.
That matters because E_i is never added to deconstructionResultsLookup (only MatchDeconstruction and MatchConversions populate it). If forwarding ever did fire for a tuple, GetAssignmentIndex would return int.MaxValue for every assignment in the block, insertPos would stay 0, and the forwarding assignment would land at the front - mispairing designators with assignments, since both builders consume them positionally. So the eager read is load-bearing, not incidental, and moving the check into the delayed action (where it reads more naturally) would silently break tuples.
| public void NestedDeconstruction_Assignment(NestedOuter o) | ||
| { | ||
| Console.WriteLine("NestedDeconstruction_Assignment:"); | ||
| var (x, (a, b)) = o; |
There was a problem hiding this comment.
Coverage suggestion: this is currently the only thing exercising the ExpressionBuilder half of the PR, and only as a side effect of the nested case. A plain non-nested var (a, b) = someStruct; would pin that fix on its own - useful since the two fixes are independent and the struct one reproduces without nesting.
It would also explain why the bug survived this long. DeconstructionSource in both fixtures is a class, and every assignment-form deconstruction in Pretty/DeconstructionTests.cs goes through it or through tuples - so nothing took the address of a struct receiver. DeconstructDictionaryForEach does deconstruct a KeyValuePair, but the foreach shape is built by StatementBuilder.TranslateDeconstructionDesignation, which never translates Pattern.TestedOperand and so never reaches VisitDeconstructInstruction.
A Pretty case would additionally lock in the emitted shape (including the inner.Deconstruct(out var a, out var b); form noted in the description), so a future change in the transform that starts reconstructing nested designations shows up as an expected-output diff rather than silently.
| { | ||
| static class KeyValuePairExtensions | ||
| { | ||
| // KeyValuePair<K, V>.Deconstruct does not exist on all target frameworks of the |
There was a problem hiding this comment.
Drop this comment completely
555e364 to
159eaf0
Compare
Optimized code stores no temporary for a deconstruction element that is used only once after the deconstruction. MatchAssignments handled that for trailing elements, but a nested deconstruction copies the inner element to a temporary, so the elements preceding it are also left without an assignment; their external load then violated the DeconstructInstruction invariant that all pattern variable loads are descendants of the instruction. The forwarding fixup now covers all unassigned elements and inserts in pattern order, because the statement and expression builders pair pattern variables with assignments positionally. This also fixes the nested tuple deconstruction crash reported in #3388. Also unwrap the address of the tested operand in VisitDeconstructInstruction: deconstructing a struct passes the receiver by reference, which was emitted as an invalid cast, 'var (x, y) = (S)(ref s);', even without nesting. Fixes #3388. Assisted-by: Claude:claude-fable-5:Claude Code
159eaf0 to
63585d1
Compare
Fixes #3803. Fixes #3388.
Nested deconstruction (
var (x, (a, b)) = o;, also theforeachform) crashed the decompiler — Debug builds assert inDeconstructInstruction.CheckInvariant, Release builds throw the reportedArgumentOutOfRangeException. This hits real-world assemblies, e.g.Microsoft.CodeAnalysis.LanguageServer.Protocol.dllshipped with the VS Code C# extension.Root cause. In optimized code a deconstruction element that is used only once after the deconstruction is not stored to a temporary.
DeconstructionTransform.MatchAssignmentscompensated for that, but only for trailing elements. A nested deconstruction copies the inner element to a temporary (the defensive copy for the innerDeconstructreceiver), which counts as the only matched assignment — leaving the leading elements without one, with their single load outside the newDeconstructInstruction. That violates the instruction's invariant that all pattern-variable loads are descendants of it, and the statement/expression builders crash on the malformed pattern.Fix. The forwarding fixup now covers all unassigned elements (guarded by an
IsDescendantOfcheck), and inserts the forwarding assignments in pattern order, sinceStatementBuilder.TranslateDeconstructionDesignationandExpressionBuilder.ConstructTuplepair pattern variables with assignments positionally.Second bug (pre-existing, surfaced by the fixture). Deconstructing a struct with a custom
Deconstructpasses the receiver by reference;VisitDeconstructInstructionemitted the address operand as an invalid cast —var (x, y) = (S)(ref s);— on released 10.1.1 even without nesting. Now unwrapped likeTranslateTargetdoes for member access targets.Output shape note. The inner deconstruction currently decompiles as an explicit
inner.Deconstruct(out var a, out var b);call rather than nested designation sugar —DeconstructionTransformnever builds nested sub-patterns (the IL node and the statement/expression builders already support them, so reconstructing the sugar is a self-contained future feature in the transform).Tests. Nested assignment,
foreach, and the #3388 KeyValuePair-with-discard shapes added to the Correctness fixture: red before (all compiler-matrix variants crash at decompile), green after, with runtime output compared against the original — so element evaluation order is verified, not just compilability. Full decompiler suite: 3332 tests, 0 failures.🤖 Generated with Claude Code