Fix the remaining deconstruction issues - #3973
Conversation
Issue #3275 reported an ArgumentOutOfRangeException in ExpressionBuilder.ConstructTuple for exactly this shape; the crash was fixed by the nested-deconstruction rework, but no fixture pinned the record-struct variant, whose Deconstruct methods are compiler-generated. Assisted-by: Claude:claude-fable-5:Claude Code
…local Deconstruction assignment copies the right-hand side into a temporary before calling Deconstruct. When the RHS is a call, inlining folds that temporary away, but for a local or parameter it survived into the output as a separate assignment statement. Consume the copy into the deconstruction pattern; rendering the copied value as the RHS recompiles to the identical temporary. Because blocks are processed back to front, the call-position match defers to the attempt starting at the copy, mirroring the existing nested-deconstruction defer guard. The new fixture also covers deconstruction assignment to locals captured by a lambda in an async method (issue #3037's crash shape, already fixed earlier). Assisted-by: Claude:claude-fable-5:Claude Code
Element 8+ of a long tuple is read through the Rest field, which Roslyn loads by value; ILSpy turned that into an addressof over the loaded copy, hiding the tuple field chain from every downstream matcher. Elide the copy when the enclosing expression only reads through it - the read then goes directly through the original address and folds into the usual Item_N chain. The deconstruction transform's use-shape guard also learns to walk that chain; whether each read really is a consumable element access remains the job of MatchTupleElementRead and the escape check. Assisted-by: Claude:claude-fable-5:Claude Code
christophwille
left a comment
There was a problem hiding this comment.
Automated high-effort review (Claude Code, multi-agent find + independent verify pass). Three correctness findings and four cleanups survived verification; three further candidates were investigated and refuted. Details are in the inline comments; the short version:
Correctness
EarlyExpressionTransforms.AddressOfLdLocToLdLoca- the newaddressof(ldobj)elision has no type-compatibility guard between the innerldobjand the enclosing read, unlike its siblingsLdObjToLdLoc/StObjToStLoc. Type-punning IL gets silently re-rooted onto memory the original never read.DeconstructionTransformdefer guard - when the deferred-to attempt atpos - 1is itself aborted byIsConsumableByEnclosingDeconstruction, the deconstruction is lost at both positions (reproduced end-to-end); master sugared this shape.IsRootDeconstructionCopyalso matches the defensive copy of a first-position nested struct element of an enclosing Deconstruct call, so the inner call gets committed as a standalone root deconstruction and the enclosing nested designation is permanently broken. Existing fixtures only cover struct elements in non-first positions, so the suite stays green.
Cleanups
MatchDeconstructionre-matches the copy+call pairIsRootDeconstructionCopyjust matched (up to three times per position); outing the copied value / matched call from the helper removes the redundancy and the drift hazard.IsRootDeconstructionCopydiscards the copied value (out _), so a byref-typed temp also matches - an in-place Deconstruct through a reference renders as a value-semantics deconstruction.- The two
AddressOfLdLocToLdLocabranches duplicateMatchAddressOfplus the whole replacement/ILRange/ReplaceWith tail.
Findings 2 and 3 each come with a concrete source shape that regresses versus master - both would make good fixture additions.
| context.EndStep(replacement); | ||
| } | ||
| else if (temp.MatchAddressOf(out addressOfTarget, out _) | ||
| && addressOfTarget is LdObj { UnalignedPrefix: 0, IsVolatile: false } innerLdObj) |
There was a problem hiding this comment.
Correctness (verified): this new ldobj(...(addressof(ldobj(addr)))) => ldobj(...(addr)) elision only checks the inner LdObj's volatile/unaligned prefixes, but never checks type compatibility between the type the addressof temporary holds and the enclosing read. The sibling transforms (LdObjToLdLoc, StObjToStLoc) guard exactly this with TypeUtils.IsCompatibleTypeForMemoryAccess.
For type-punning IL (hand-written or obfuscated, not Roslyn-emitted) - e.g. ldobj T2(addressof(ldobj T1(addr))) with sizeof(T2) > sizeof(T1), or an ldflda chain whose declaring type does not match the inner ldobj's type - the rewrite re-roots the read directly onto addr: the original program read bytes out of a temporary copy of T1, while the transformed expression reads adjacent live memory behind addr that the original never touched. A type-compatibility check between the inner ldobj type and the addressof type (mirroring the sibling transforms) would restrict the elision to the Roslyn shape it targets.
| // Same idea for the value-semantics copy before a root Deconstruct call: blocks are | ||
| // processed back to front, so defer the call-only match to the attempt starting at | ||
| // the copy, which can consume both (see MatchDeconstruction). | ||
| if (pos > 0 && IsRootDeconstructionCopy(block.Instructions[pos - 1], block.Instructions[pos])) |
There was a problem hiding this comment.
Correctness (verified, reproduced end-to-end): this guard unconditionally defers the call-only match at pos whenever the copy+call shape holds at pos - 1, but the deferred-to attempt at pos - 1 can itself be aborted before MatchDeconstruction runs: when the copy's value is a tuple-element read of an earlier designation temporary, IsConsumableByEnclosingDeconstruction (line 130) returns true for pos - 1 via HasEnclosingTupleDesignation, and that attempt bails. The back-to-front walk never returns to pos, so the deconstruction is lost at both positions.
Repro shape: var inner = big.Item1; var (x, y) = inner.Item1; (element type has a Deconstruct method). On master the call position matched and emitted var (x, y) = ...;; with this guard the output regresses to an explicit temporary plus a raw .Deconstruct(out ...) call. Deferring only when the pos - 1 attempt will actually consume the pair (i.e. not itself deferred/aborted) would fix it.
Minor, related: the guard here and the consuming check inside MatchDeconstruction encode the same adjacency test at shifted offsets ((pos-1, pos) vs (pos, pos+1)), coupled only by comments. If the consuming side is ever tightened without mirroring this guard, positions defer to an attempt that no longer consumes them and the sugar is silently lost. Keeping both sides on the identical shared predicate is what holds this together - worth preserving deliberately.
| rootCall = null; | ||
| testedOperand = null; | ||
| if (pos + 1 < block.Instructions.Count | ||
| && IsRootDeconstructionCopy(block.Instructions[pos], block.Instructions[pos + 1])) |
There was a problem hiding this comment.
Correctness (verified): IsRootDeconstructionCopy also matches the defensive copy of a nested struct element of an enclosing Deconstruct call, so the attempt starting at that copy commits the inner call as a standalone root deconstruction and permanently breaks the enclosing nested designation. The back-to-front walk visits the copy before the enclosing call, and nothing defers at the copy position: IsConsumableByEnclosingDeconstruction needs a CallInstruction at pos, and the pos - 1 guard sees the enclosing call, not a stloc.
Repro shape: var ((a, b), c) = GetSource<StructDeconstructionSource<int, string>, int>(); - a struct element with its own Deconstruct in first position, conversion-free leaves. At the copy position, MatchDeconstruction consumes copy+inner call and commits (a, b) = s;; the enclosing call then has no assignments left and stays explicit. Output regresses from master's var ((a, b), c) = src; to src.Deconstruct(out var s, out var num); (a, b) = s; int c = num;.
The existing fixtures only place struct elements in non-first positions (BothElementsNested has the class element first), so the suite stays green while this shape regresses - a first-position fixture would catch it.
| if (pos + 1 < block.Instructions.Count | ||
| && IsRootDeconstructionCopy(block.Instructions[pos], block.Instructions[pos + 1])) | ||
| { | ||
| block.Instructions[pos].MatchStLoc(out _, out var copiedValue); |
There was a problem hiding this comment.
Cleanup: this branch re-matches instructions IsRootDeconstructionCopy just matched - a MatchStLoc whose bool result is discarded, plus a second MatchDeconstructionCall on the same call instruction (a third counting the TransformDeconstruction guard). Having IsRootDeconstructionCopy return the copied value and the matched DeconstructionCall via out parameters removes the redundant work and the lockstep hazard: today, if the helper is ever loosened (say, to accept a stobj-shaped copy), the ignored-result MatchStLoc(out _, out var copiedValue) silently leaves copiedValue wrong instead of failing the match.
The || testedOperand == null check below is also dead: MatchDeconstructionCall sets testedOperand from call.Arguments[0] after checking Arguments.Count >= 3.
| /// </summary> | ||
| bool IsRootDeconstructionCopy(ILInstruction copyInst, ILInstruction callInst) | ||
| { | ||
| if (!copyInst.MatchStLoc(out var copy, out _)) |
There was a problem hiding this comment.
Strictness: the copied value is discarded (out _), so the helper never checks that this is actually a by-value copy. A byref-typed temp - stloc r(ldflda f(x)); call Deconstruct(ldloc r, ...) - satisfies every condition here (MatchLdLocOrLdLoca accepts the plain ldloc r receiver), and an in-place Deconstruct call through a reference gets rendered as a value-semantics deconstruction of x.f. If Deconstruct mutates its receiver struct, the decompiled (a, b) = x.f; recompiles to different behavior.
The XML doc describes the target shape as the value-semantics temporary for a local or parameter; constraining the copied value (non-byref copy variable type, or value matching ldloc/ldobj) keeps the matcher at that guaranteed shape.
| temp.ReplaceWith(replacement); | ||
| context.EndStep(replacement); | ||
| } | ||
| else if (temp.MatchAddressOf(out addressOfTarget, out _) |
There was a problem hiding this comment.
Cleanup: the two branches are near-duplicates - temp.MatchAddressOf is evaluated twice (every visited LdObj pays the second match when the first branch fails), and the replacement/ILRange-transfer/ReplaceWith/EndStep boilerplate is duplicated. A single if (temp.MatchAddressOf(out var addressOfTarget, out var type)) that dispatches on addressOfTarget (MatchLdLoc vs LdObj) to compute the replacement, sharing one range-transfer loop and one ReplaceWith/EndStep tail, does the same job with half the surface - and gives the missing type check (see the comment on the LdObj branch) a single place to live.
Fixes #3208, fixes #3453, fixes #3962. Closes #2322, closes #3037, closes #3275: those three no longer reproduce on master (#3275 was fixed by #3949), and this PR adds the missing regression fixtures for them, so they can be closed with it.
Triage first: each issue's repro was compiled and decompiled against current master. The three crashes/wrong-code reports (#2322, #3037, #3275) are already fixed - #2322 is covered by the existing
DeconstructDictionaryForEachfixture, and this PR adds fixtures for #3037 (deconstruction assignment to locals captured by a lambda in an async method) and #3275 (nested deconstruction of readonly record structs). What remained collapses into two defects:Value-semantics copy left behind (#3453, #3208). Deconstruction assignment copies the right-hand side into a temporary before calling
Deconstruct. When the RHS is a call, inlining folds the temporary; for a local or parameter it survived into the output as a separate statement (CalibrationPoint calibrationPoint = point; var (num, num2) = calibrationPoint;).DeconstructionTransformnow consumes the copy into the pattern - rendering the copied value as the RHS recompiles to the identical temporary. Because blocks are matched back to front, the call-position match defers to the attempt starting at the copy, mirroring the existing nested-deconstruction defer guard.Rest-chained inner tuple designation (#3962). Element 8+ of a long tuple is read through theRestfield, which Roslyn loads by value; ILSpy rendered that as anaddressof(ldobj(...))sandwich that hid the tuple field chain from every downstream matcher (including the existingRestflattening inMatchTupleFieldAccess). Two-part fix at the failure origin:EarlyExpressionTransformselides the copy when the enclosing expression only reads through it, and the deconstruction transform's use-shape guard walks the resultingldfldachain instead of requiring exactly one field access.var (x, (a, b, c, d, e, f, g, h)) = ...now reconstructs fully; as a side effect, plain reads render asitem.Item8instead ofitem.Rest.Item1.Each fix was developed fixture-first (red on master, green after); the full decompiler suite passes on every commit.
This PR description was written by the Claude Code agent session that authored the change.
🤖 Generated with Claude Code