From 520c507d55ca96720e37440a20840535ce8dadb9 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sun, 9 Aug 2026 13:24:25 +0200 Subject: [PATCH 1/3] Cover nested deconstruction of record structs with a fixture 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 --- .../TestCases/Pretty/Records.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Records.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Records.cs index 1a88b0f4eb..75f65536fa 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Records.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Records.cs @@ -285,6 +285,10 @@ public record struct Pair public record struct PairWithPrimaryCtor(A First, B Second); + public readonly record struct BoundsInfo(int Profile, Bounds Bounds); + + public readonly record struct Bounds(ulong Small, ulong Large); + public record struct PrimaryCtor(int A, string B); public record struct MultipleCtorsNoPrimaryCtor @@ -447,6 +451,19 @@ public RecordCtorChain(string B) C = 1.41; } } + + private static BoundsInfo GetInfo() + { + return new BoundsInfo(1, new Bounds(2uL, 3uL)); + } + + public static void NestedDeconstruction() + { + var (value, (value2, value3)) = GetInfo(); + Console.WriteLine(value); + Console.WriteLine(value2); + Console.WriteLine(value3); + } } internal class RecordsWithCustomSynthesizedMembers From b53f19dd75aa9c55b180a6ddacb3690b39b974c4 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sun, 9 Aug 2026 13:35:46 +0200 Subject: [PATCH 2/3] Fix #3453, #3208: inline the value-semantics copy of a deconstructed 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 --- .../TestCases/Pretty/DeconstructionTests.cs | 45 +++++++++++++ .../IL/Transforms/DeconstructionTransform.cs | 65 ++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs index 1fc53d916a..21818fe18f 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs @@ -19,6 +19,7 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Threading.Tasks; namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty { @@ -419,6 +420,26 @@ public void LocalVariable_Nested_BothElementsNested() Console.WriteLine(value2); } + public void LocalVariable_Nested_StructInnerFirstElement() + { + var ((value, value2), value3) = GetSource, int>(); + Console.WriteLine(value); + Console.WriteLine(value2); + Console.WriteLine(value3); + } + + public void LocalVariable_ElementOfElementRead_ThenDeconstruct() + { + ((StructDeconstructionSource, int), int) tuple = GetTuple<(StructDeconstructionSource, int), int>(); + (StructDeconstructionSource, int) item = tuple.Item1; + StructDeconstructionSource item2 = item.Item1; + var (value, value2) = item2; + Console.WriteLine(value); + Console.WriteLine(value2); + Console.WriteLine(item.Item2); + Console.WriteLine(tuple.Item2); + } + public void LocalVariable_Nested_Depth3() { var (myInt3, (myInt4, (value, value2))) = GetSource>>(); @@ -964,5 +985,29 @@ public void DeconstructTupleListForEach(List<(string, int)> tuples) Console.WriteLine(text + ": " + num); } } + + public async Task DeconstructionAssignmentToCapturedLocals(string file) + { + int a = 0; + int b = 0; + await Task.Run(delegate { + (a, b) = GetTuple(); + }); + return a + b; + } + + public bool DeconstructStructParameter(StructDeconstructionSource point) + { + var (num2, value) = point; + Console.WriteLine(value); + return num2 >= 0; + } + + public void DeconstructStructLocal() + { + StructDeconstructionSource structSource = GetStructSource(); + var (num2, text2) = structSource; + Console.WriteLine(num2 + text2 + structSource.Dummy); + } } } diff --git a/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs b/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs index 63324af82d..f3e0615053 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs @@ -129,6 +129,17 @@ bool TransformDeconstruction(Block block, int pos) // exists (see the guard for the precision guarantees). if (IsConsumableByEnclosingDeconstruction(block, pos)) return false; + // 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). Defer only when that + // attempt actually reaches its match: an attempt that is itself deferred to an + // enclosing pattern bails without consuming this position, and the back-to-front + // walk never comes back, which would lose the deconstruction at both positions. + if (pos > 0 && IsRootDeconstructionCopy(block, pos - 1, out _, out _) + && !IsConsumableByEnclosingDeconstruction(block, pos - 1)) + { + return false; + } if (!MatchDeconstructionSequence(block, startPos, out pos, out var rootCall, out var rootTestedOperand, out var conversionStLocs, out var delayedActions)) { @@ -431,7 +442,19 @@ sealed class DeconstructionCall void MatchDeconstruction(Block block, ref int pos, out DeconstructionCall? rootCall, out ILInstruction? testedOperand) { - rootCall = MatchDeconstructionCall(block.Instructions[pos], out testedOperand); + // Deconstruction assignment has value semantics, so Roslyn copies the deconstructed + // value into a temporary and calls Deconstruct on that. When the value is a call + // result, inlining already folds the temporary away; when it is a local or parameter, + // the copy survives to here. Consume it into the pattern: rendering the copied value + // as the deconstruction target recompiles to the identical temporary. + rootCall = null; + testedOperand = null; + if (IsRootDeconstructionCopy(block, pos, out var copiedValue, out rootCall)) + { + testedOperand = copiedValue; + pos++; + } + rootCall ??= MatchDeconstructionCall(block.Instructions[pos], out testedOperand); if (rootCall == null) return; rootedInDeconstructCall = true; @@ -460,6 +483,46 @@ static void CollectLeaves(DeconstructionCall call, List leaves) } } + /// + /// stloc copy(value) at pos + /// call Deconstruct(ldloc(a) copy, ...) a root Deconstruct call on the copy + /// where the copy has no other use: the value-semantics temporary Roslyn emits for a + /// deconstruction whose right-hand side is not already a temporary. On success, + /// is the deconstructed value and + /// the matched call, so callers need not re-match either. + /// + bool IsRootDeconstructionCopy(Block block, int pos, out ILInstruction? copiedValue, + out DeconstructionCall? call) + { + copiedValue = null; + call = null; + if (pos + 1 >= block.Instructions.Count) + return false; + if (!block.Instructions[pos].MatchStLoc(out var copy, out var value)) + return false; + if (copy.Kind is not (VariableKind.Local or VariableKind.StackSlot)) + return false; + // A byref temporary is not a copy: Deconstruct called through it acts on the original, + // which is not what a value-semantics deconstruction of the referenced expression does. + if (copy.StackType == StackType.Ref) + return false; + if (!(copy.StoreCount == 1 && copy.LoadCount + copy.AddressCount == 1)) + return false; + // The defensive copy of a struct element of an enclosing Deconstruct call has this + // exact shape. It belongs to the enclosing call's nested designation, so consuming it + // here would commit the inner call on its own and break the designation for good. + if (TryFindEnclosingDeconstructionCall(block, pos + 1, out _)) + return false; + var matchedCall = MatchDeconstructionCall(block.Instructions[pos + 1], out var testedOperand); + if (matchedCall == null) + return false; + if (!MatchLdLocOrLdLoca(testedOperand!, out var receiver) || receiver != copy) + return false; + copiedValue = value; + call = matchedCall; + return true; + } + /// /// call(virt) Deconstruct(target, ldloca out0, ldloca out1, ...) /// where every out-argument is a single-use temporary. From 4aa9d87bf148d7da590b9336d0e02f2b67a8d7c7 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sun, 9 Aug 2026 13:46:17 +0200 Subject: [PATCH 3/3] Fix #3962: reconstruct nested designations whose inner tuple uses Rest 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 --- .../TestCases/Pretty/DeconstructionTests.cs | 14 +++++++ .../IL/Transforms/DeconstructionTransform.cs | 11 +++++- .../Transforms/EarlyExpressionTransforms.cs | 38 ++++++++++++++----- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs index 21818fe18f..a07a220e37 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs @@ -996,6 +996,20 @@ await Task.Run(delegate { return a + b; } + public void NestedDesignation_RestChainedInnerTuple() + { + var (value, (value2, value3, value4, value5, value6, value7, value8, value9)) = GetTuple(); + Console.WriteLine(value); + Console.WriteLine(value2); + Console.WriteLine(value3); + Console.WriteLine(value4); + Console.WriteLine(value5); + Console.WriteLine(value6); + Console.WriteLine(value7); + Console.WriteLine(value8); + Console.WriteLine(value9); + } + public bool DeconstructStructParameter(StructDeconstructionSource point) { var (num2, value) = point; diff --git a/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs b/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs index f3e0615053..e7ff8d78ec 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs @@ -731,7 +731,16 @@ static bool AllUsesAreTupleElementReads(ILVariable temp) { foreach (var use in temp.AddressInstructions.Concat(temp.LoadInstructions)) { - if (!(use.Parent is LdFlda elementAccess && elementAccess.Parent is LdObj)) + // Walk the ldflda chain up to the reading ldobj: element 8+ of a long tuple + // is accessed through the Rest field, i.e. through more than one ldflda. + // Whether each read is really a tuple element access (and consumed by the + // pattern) is verified by MatchTupleElementRead and the escape check. + ILInstruction? node = use.Parent; + if (node is not LdFlda) + return false; + while (node is LdFlda ldflda) + node = ldflda.Parent; + if (node is not LdObj) return false; } return true; diff --git a/ICSharpCode.Decompiler/IL/Transforms/EarlyExpressionTransforms.cs b/ICSharpCode.Decompiler/IL/Transforms/EarlyExpressionTransforms.cs index 48e923585a..aa4e79cde9 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/EarlyExpressionTransforms.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/EarlyExpressionTransforms.cs @@ -163,17 +163,37 @@ internal static void AddressOfLdLocToLdLoca(LdObj inst, ILTransformContext conte temp = ldfldaTarget; range = range.Concat(temp.ILRanges); } - if (temp.MatchAddressOf(out var addressOfTarget, out _) && addressOfTarget.MatchLdLoc(out var v)) + if (!temp.MatchAddressOf(out var addressOfTarget, out var addressOfType)) + return; + ILInstruction replacement; + switch (addressOfTarget) { - context.Step($"ldobj(...(addressof(ldloca {v.Name}))) => ldobj(...(ldloca {v.Name}))", inst); - var replacement = new LdLoca(v).WithILRange(addressOfTarget); - foreach (var r in range) - { - replacement = replacement.WithILRange(r); - } - temp.ReplaceWith(replacement); - context.EndStep(replacement); + case LdLoc { Variable: var v }: + context.Step($"ldobj(...(addressof(ldloca {v.Name}))) => ldobj(...(ldloca {v.Name}))", inst); + replacement = new LdLoca(v).WithILRange(addressOfTarget); + break; + // The enclosing ldobj only reads through the temporary the addressof creates, so + // the copy can be elided and the read go directly through the inner address. + // This shape arises when a struct field chain is read by value (ldfld, then a + // field of the loaded value), e.g. reading element 8+ of a long tuple via Rest. + // The type check keeps the elision on that shape: re-rooting a read of an + // incompatible type onto the inner address would read memory behind it that the + // copy never covered. + case LdObj { UnalignedPrefix: 0, IsVolatile: false } innerLdObj + when TypeUtils.IsCompatibleTypeForMemoryAccess(innerLdObj.Type, addressOfType): + context.Step("ldobj(...(addressof(ldobj(addr)))) => ldobj(...(addr))", inst); + replacement = innerLdObj.Target; + range = range.Concat(addressOfTarget.ILRanges); + break; + default: + return; + } + foreach (var r in range) + { + replacement = replacement.WithILRange(r); } + temp.ReplaceWith(replacement); + context.EndStep(replacement); } protected internal override void VisitCall(Call inst)