Browse Source

Generalise runtime-async flag-based early-return for nested try-finally.

The flag-based early-return rewriter was tied to one specific lowered shape:
the try body's flag-setter had to be exactly `stloc flag(K); leave try`, the
post-try check had to be a `br checkBlock` (not an inline `IfInstruction`), and
the early path had to be a direct Leave or a forward to a one-instruction
leave-block whose target was the function body. None of those hold for
`try { try { return X; } finally { await ... } } finally { await ... }`:

  - The inner flag-setter has a leading capture-forwarding store
    (`stloc capture(X); stloc innerFlag(K); leave inner-try`).
  - The inner check-block's early path branches to a multi-instruction helper
    that sets the *outer* flag and leaves the outer try, instead of being a
    direct return.
  - SplitVariables hands out a separate ILVariable for the pre-init flag store
    when the in-handler store is in a disjoint dataflow region.

Rebuild the matcher around the idea of a "template" — the chain of stores
the early path performs before its terminating Leave. Each flag-setter then
becomes its own prefix stores + a clone of the template, which collapses the
inner-then-outer flag chain in two passes (inner first, outer second, because
descendant order visits the inner TryFinally first). Also extend the
flag-setter scan to walk the whole try-block's descendants — after the inner
rewrite, the inner's spliced flag-setter lives inside the inner-try container
but still leaves outwards to the outer try, so it's an outer flag-setter from
the outer's perspective.

Add a `RUNTIMEASYNC` preprocessor symbol (defined when `EnableRuntimeAsync`
is set) and gate the new return-from-try-finally fixtures on it — the
state-machine async pipeline doesn't recover this shape, so it would expand
the same source into the `int result; try { ...; result = X; } finally { ... }
return result;` verbose form and the Async (state-machine) pretty test would
regress.

Closes Cluster 1 (1.1, 1.3) from #3745. Cluster 1.2 (void `return;` at the
end of a try-finally body) and 1.4 (break/continue across a try-finally) are
left for a follow-up: both round-trip semantically equivalently but the AST
emitter drops a trailing void `return;` and the break/continue lowering uses
a switch dispatch that the current single-K matcher can't recognize.
pull/3731/head
Siegfried Pammer 3 days ago
parent
commit
8e2e48f5cc
  1. 4
      ICSharpCode.Decompiler.Tests/Helpers/Tester.cs
  2. 36
      ICSharpCode.Decompiler.Tests/TestCases/Pretty/Async.cs
  3. 312
      ICSharpCode.Decompiler/IL/ControlFlow/RuntimeAsyncExceptionRewriteTransform.cs

4
ICSharpCode.Decompiler.Tests/Helpers/Tester.cs

@ -501,6 +501,10 @@ namespace System.Runtime.CompilerServices @@ -501,6 +501,10 @@ namespace System.Runtime.CompilerServices
preprocessorSymbols.Add("LEGACY_CSC");
preprocessorSymbols.Add("LEGACY_VBC");
}
if (flags.HasFlag(CompilerOptions.EnableRuntimeAsync))
{
preprocessorSymbols.Add("RUNTIMEASYNC");
}
return preprocessorSymbols;
}

36
ICSharpCode.Decompiler.Tests/TestCases/Pretty/Async.cs

@ -414,6 +414,42 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty @@ -414,6 +414,42 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty
await Task.Yield();
}
}
#if RUNTIMEASYNC
// The state-machine async lowering doesn't recognize return-from-try-with-await-in-finally
// and decompiles these as `int result; try { ... } finally { ... } return result;`. The
// runtime-async exception rewrite recovers the source-level form. Gate these tests so the
// (state-machine) Async test doesn't run them against the more aggressive output.
public async Task<int> ReturnFromTryFinally()
{
try
{
return 42;
}
finally
{
await Task.CompletedTask;
}
}
public async Task<int> ReturnFromInsideNestedTryFinally()
{
try
{
try
{
return 42;
}
finally
{
await Task.CompletedTask;
}
}
finally
{
await Task.CompletedTask;
}
}
#endif
#endif
public static async Task<int> GetIntegerSumAsync(IEnumerable<int> items)

312
ICSharpCode.Decompiler/IL/ControlFlow/RuntimeAsyncExceptionRewriteTransform.cs

@ -22,6 +22,7 @@ using System.Linq; @@ -22,6 +22,7 @@ using System.Linq;
using ICSharpCode.Decompiler.IL.Transforms;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.Decompiler.Util;
namespace ICSharpCode.Decompiler.IL.ControlFlow
{
@ -1007,45 +1008,56 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -1007,45 +1008,56 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
return false;
if (parentBlock.Parent is not BlockContainer container)
return false;
// The TryFinally is followed in parentBlock by either nothing (fall-through into the next block)
// or a single `br checkBlock` we appended ourselves. Locate the post-try check block.
Block checkBlock;
// The TryFinally is followed in parentBlock by either a `br checkBlock` or an inline
// IfInstruction (when Roslyn merges the flag check into the parent block). Identify the
// (block, startIndex) pair where the flag-check sequence lives.
int tryFinallyIdx = tryFinally.ChildIndex;
if (tryFinallyIdx == parentBlock.Instructions.Count - 1)
return false;
if (parentBlock.Instructions[tryFinallyIdx + 1] is Branch br)
checkBlock = br.TargetBlock;
else
return false;
if (checkBlock?.Parent != container)
return false;
// Block checkBlock { if (flagVar == K) br earlyBlock; br normalBlock }
// or: { if (flagVar != K) br normalBlock; br earlyBlock }
if (checkBlock.Instructions.Count != 2)
return false;
if (checkBlock.Instructions[0] is not IfInstruction ifInst)
Block checkBlock;
int checkStartIndex;
bool checkInline;
if (parentBlock.Instructions[tryFinallyIdx + 1] is Branch brToCheck
&& brToCheck.TargetBlock?.Parent == container)
{
checkBlock = brToCheck.TargetBlock;
checkStartIndex = 0;
checkInline = false;
}
else if (parentBlock.Instructions[tryFinallyIdx + 1] is IfInstruction)
{
checkBlock = parentBlock;
checkStartIndex = tryFinallyIdx + 1;
checkInline = true;
}
else
{
return false;
if (ifInst.TrueInst is not Branch toIfTrue)
}
// The flag-check sequence is two consecutive instructions: an IfInstruction guarding on
// `flagVar` and a fall-through. Identify the "early" path (taken when flag == K) and the
// "normal" path (the other) — each can be either a Branch to a block (we then need to
// follow it via ResolveEarlyReturnValue), or a direct Leave / Throw.
if (checkStartIndex + 1 >= checkBlock.Instructions.Count)
return false;
if (!checkBlock.Instructions[1].MatchBranch(out var fallthroughBlock))
if (checkBlock.Instructions[checkStartIndex] is not IfInstruction ifInst)
return false;
ILVariable flagVar;
int targetK;
Block earlyBlock, normalBlock;
ILInstruction earlyAction, normalAction;
if (ifInst.Condition.MatchCompEquals(out var lhs, out var rhs)
&& lhs.MatchLdLoc(out flagVar) && rhs.MatchLdcI4(out targetK))
{
earlyBlock = toIfTrue.TargetBlock;
normalBlock = fallthroughBlock;
earlyAction = ifInst.TrueInst;
normalAction = checkBlock.Instructions[checkStartIndex + 1];
}
else if (ifInst.Condition.MatchCompNotEquals(out lhs, out rhs)
&& lhs.MatchLdLoc(out flagVar) && rhs.MatchLdcI4(out targetK))
{
normalBlock = toIfTrue.TargetBlock;
earlyBlock = fallthroughBlock;
normalAction = ifInst.TrueInst;
earlyAction = checkBlock.Instructions[checkStartIndex + 1];
}
else
{
@ -1053,31 +1065,49 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -1053,31 +1065,49 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
}
if (!flagVar.Type.IsKnownType(KnownTypeCode.Int32))
return false;
if (earlyBlock?.Parent != container || normalBlock?.Parent != container)
return false;
// earlyBlock chain: optional `stloc returnVar(ldloc capture); br leaveBlock` followed by
// `leave outer (ldloc returnVar)` (or a direct leave with the capture).
if (!ResolveEarlyReturnValue(earlyBlock, container, out var captureVar, out var returnVar, out var leaveBlock))
// Resolve the early-action into a list of instructions to splice into each flag-setter.
List<ILInstruction> earlyActionTemplate;
if (earlyAction is Leave earlyLeave && IsLeaveToContainerOrAncestor(earlyLeave, container))
{
earlyActionTemplate = new List<ILInstruction> { earlyLeave };
}
else if (earlyAction is Branch earlyBranch
&& earlyBranch.TargetBlock?.Parent == container
&& TryGetEarlyActionTemplate(earlyBranch.TargetBlock, container, out earlyActionTemplate))
{
// templated
}
else
{
return false;
}
// Inside the try-block find the flag-setter block(s): `stloc flagVar(K); leave-tryBlock`.
// There may be multiple — e.g. several catches in a multi-handler try each with its own
// early-return — but for the simple case we only need one.
// Find flag-setter blocks anywhere inside the try-block (including nested containers).
// Shape: zero or more capture-forwarding stores, then `stloc flagVar(K); leave tryBlock`.
// Walking descendants picks up the post-rewrite spliced inner-flag-setters in the nested
// try-finally case — those live inside the inner try-block container but still leave to
// the outer try-block (because the splice inlined the outer's flag-set + leave).
if (tryFinally.TryBlock is not BlockContainer tryBlockContainer)
return false;
var flagSetters = new List<Block>();
foreach (var b in tryBlockContainer.Blocks)
foreach (var b in tryBlockContainer.Descendants.OfType<Block>())
{
if (b.Instructions.Count == 2
&& b.Instructions[0] is StLoc setStore
&& setStore.Variable == flagVar
&& setStore.Value.MatchLdcI4(targetK)
&& b.Instructions[1] is Leave leaveFromTry
&& leaveFromTry.TargetContainer == tryBlockContainer)
int n = b.Instructions.Count;
if (n < 2)
continue;
if (b.Instructions[n - 2] is not StLoc setStore
|| !FlagVariableMatches(setStore.Variable, flagVar)
|| !setStore.Value.MatchLdcI4(targetK))
{
continue;
}
if (b.Instructions[n - 1] is not Leave leaveFromTry
|| leaveFromTry.TargetContainer != tryBlockContainer)
{
flagSetters.Add(b);
continue;
}
flagSetters.Add(b);
}
if (flagSetters.Count == 0)
return false;
@ -1092,40 +1122,72 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -1092,40 +1122,72 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
return false;
}
// Build the leave instruction shape: leave outer (ldloc captureSource).
var outerContainer = (BlockContainer)leaveBlock.Parent;
var outerLeave = (Leave)leaveBlock.Instructions[^1];
if (outerLeave.TargetContainer != outerContainer)
return false;
context.StepStartGroup("Reduce runtime-async flag-based early return", tryFinally);
// Single pass over the try body: every Branch targeting a flag-setter is the tail of an
// early-return site (`...; stloc capture(value); br fs`). Replace each with a direct
// `leave outer (ldloc capture)`. The capture store stays and feeds the leave value via the
// variable read. Then drop the flag-setter blocks; they're unreachable post-rewrite.
var flagSetterSet = new HashSet<Block>(flagSetters);
foreach (var pred in tryBlockContainer.Descendants.OfType<Branch>().ToArray())
// Replace each flag-setter's leave-tryBlock with a leave directly to leaveTargetContainer
// (and, for value-returning sites, with the captured value). The capture-forwarding stores
// before the flag setter stay; downstream cleanup drops them once the read in `earlyBlock`
// disappears. Clear the spliced clones' top-level ILRange so each splice site keeps its
// own sequence points instead of all claiming the source offset of the early-action.
foreach (var fs in flagSetters)
{
if (!flagSetterSet.Contains(pred.TargetBlock))
continue;
pred.ReplaceWith(new Leave(outerContainer, new LdLoc(captureVar)).WithILRange(pred));
int n = fs.Instructions.Count;
// Drop the stloc flagVar(K) and the trailing leave-tryBlock. Splice in a clone of
// the early-action template so the flag-setter's capture-forwarding stores flow
// directly into the early-action's stores + final leave.
fs.Instructions.RemoveAt(n - 1);
fs.Instructions.RemoveAt(n - 2);
foreach (var inst in earlyActionTemplate)
{
var spliced = inst.Clone();
spliced.SetILRange(new Interval());
fs.Instructions.Add(spliced);
}
}
// Replace the flag-check sequence with the normal-path action. The early-path is now
// taken by the rewritten flag-setters themselves, so the only remaining successor is
// the normal path. Clone the normal-action so we don't have to worry about whose tree
// it currently belongs to (it may be a child of the if-instruction we're tearing down),
// and clear its ILRange — the clone lives at a different location than the original.
var normalActionClone = normalAction.Clone();
normalActionClone.SetILRange(new Interval());
if (checkInline)
{
parentBlock.Instructions.RemoveAt(checkStartIndex + 1);
parentBlock.Instructions.RemoveAt(checkStartIndex);
parentBlock.Instructions.Add(normalActionClone);
}
else
{
checkBlock.Instructions.Clear();
checkBlock.Instructions.Add(normalActionClone);
}
foreach (var fs in flagSetters)
fs.Remove();
// The post-flag-check block can now be replaced with `br normalBlock`. The flag write/read,
// the early-return chain and (eventually) the captureVar/returnVar become unreferenced.
checkBlock.Instructions.Clear();
checkBlock.Instructions.Add(new Branch(normalBlock));
// After the splice each flag-setter exits via a Leave that doesn't return to the
// surrounding try-block, so any block whose only entry was the now-cleared check-block
// is now unreachable. Drop those so the HasFlag check below sees the post-rewrite truth.
tryBlockContainer.SortBlocks(deleteUnreachableBlocks: true);
// After rewrite, the TryFinally may have unreachable endpoint (all flag-setter sites
// became Leaves to an ancestor container, so the try-body no longer has a Leave-to-tryBlock).
// The post-TryFinally instructions in parentBlock are then dead — and leaving them in place
// would put a non-final unreachable-endpoint instruction in the block, violating the block
// invariant. Drop everything after the TryFinally in that case.
if (tryFinally.HasFlag(InstructionFlags.EndPointUnreachable))
{
while (parentBlock.Instructions.Count > tryFinally.ChildIndex + 1)
parentBlock.Instructions.RemoveAt(parentBlock.Instructions.Count - 1);
}
// Drop the pre-try `stloc flagVar(0)`.
for (int i = 0; i < tryFinallyIdx; i++)
for (int i = 0; i < parentBlock.Instructions.Count; i++)
{
if (parentBlock.Instructions[i] is StLoc s && s.Variable == flagVar && s.Value.MatchLdcI4(0))
if (parentBlock.Instructions[i] is StLoc s
&& FlagVariableMatches(s.Variable, flagVar)
&& s.Value.MatchLdcI4(0))
{
parentBlock.Instructions.RemoveAt(i);
tryFinallyIdx--;
i--;
}
}
@ -1134,49 +1196,143 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -1134,49 +1196,143 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
return true;
}
// earlyBlock should be either:
// `stloc returnVar(ldloc capture); br leaveBlock` followed by leaveBlock = `leave outer (ldloc returnVar)`
// or a direct `leave outer (ldloc capture)`.
// True when `candidate` is the same slot/kind/type as `flagVar` — handles SplitVariables splitting
// the pre-init off from the in-try set.
static bool FlagVariableMatches(ILVariable candidate, ILVariable flagVar)
{
if (candidate == flagVar)
return true;
return candidate.Index == flagVar.Index
&& candidate.Kind == flagVar.Kind
&& candidate.Type.IsKnownType(KnownTypeCode.Int32);
}
// Build a template of instructions to splice into each flag-setter when the early-action is
// a Branch to a helper block. Follows a chain of "stores + br to next block" until reaching
// a block whose last instruction is a Leave to `container` or an ancestor. The final template
// is the concatenation of all the stores encountered, followed by the terminating Leave.
//
// Shapes covered:
// - [Leave container (value?)] — direct leave
// - [br leaveBlock]; leaveBlock = [Leave container] — one-hop forwarding
// - [stloc returnVar(...); br leaveBlock] — indirected value return
// - [stloc r(...); br fwd]; fwd = [stloc f(K); Leave outer]
// — nested forwarding (sets the
// enclosing try-finally's flag and leaves outer, so the next round of this transform
// picks it up at that outer level)
static bool TryGetEarlyActionTemplate(Block earlyBlock, BlockContainer container, out List<ILInstruction> template)
{
template = new List<ILInstruction>();
var visited = new HashSet<Block>();
var current = earlyBlock;
while (true)
{
if (!visited.Add(current))
{
template = null;
return false;
}
if (current.Instructions.Count == 0)
{
template = null;
return false;
}
for (int i = 0; i < current.Instructions.Count - 1; i++)
template.Add(current.Instructions[i]);
var last = current.Instructions[^1];
if (last is Leave leave && IsLeaveToContainerOrAncestor(leave, container))
{
template.Add(leave);
return true;
}
if (last.MatchBranch(out var next))
{
current = next;
continue;
}
template = null;
return false;
}
}
// Resolve `earlyBlock` to (captureVar?, leaveTargetContainer). Used by the simpler shapes;
// callers that need to handle the multi-instruction nested-forwarding form use
// `TryGetEarlyActionTemplate` instead.
// - direct leave: `[Leave container (value?)]`
// - direct branch-to-leave: `[br leaveBlock]` where `leaveBlock = [Leave container (void)]`
// - indirected: `[stloc returnVar(ldloc capture); br leaveBlock]` where
// `leaveBlock = [Leave container (ldloc returnVar)]`
// The target container can be the function body (top-level return) or any ancestor of the
// surrounding container — break/continue across a try-finally lower to a Leave to the loop
// or its parent.
static bool ResolveEarlyReturnValue(Block earlyBlock, BlockContainer container,
out ILVariable captureVar, out ILVariable returnVar, out Block leaveBlock)
out ILVariable captureVar, out BlockContainer leaveTargetContainer)
{
captureVar = null;
returnVar = null;
leaveBlock = null;
leaveTargetContainer = null;
// Direct shape: earlyBlock is just `leave outer (ldloc capture)`.
// Direct leave: earlyBlock is just `Leave container (value?)`.
if (earlyBlock.Instructions.Count == 1
&& earlyBlock.Instructions[0] is Leave directLeave
&& directLeave.IsLeavingFunction
&& directLeave.Value.MatchLdLoc(out captureVar))
&& IsLeaveToContainerOrAncestor(directLeave, container))
{
leaveTargetContainer = directLeave.TargetContainer;
if (directLeave.Value.MatchLdLoc(out captureVar))
return true;
if (directLeave.Value.OpCode == OpCode.Nop)
{
captureVar = null;
return true;
}
return false;
}
// Direct branch-to-leave (void return shape): earlyBlock is `[br leaveBlock]` where
// `leaveBlock = [Leave container (void)]`.
if (earlyBlock.Instructions.Count == 1
&& earlyBlock.Instructions[0].MatchBranch(out var brTarget)
&& brTarget.Instructions.Count == 1
&& brTarget.Instructions[0] is Leave brLeave
&& IsLeaveToContainerOrAncestor(brLeave, container)
&& brLeave.Value.OpCode == OpCode.Nop)
{
leaveBlock = earlyBlock;
leaveTargetContainer = brLeave.TargetContainer;
captureVar = null;
return true;
}
// Indirected shape: earlyBlock copies the capture into a returnVar and branches to a
// one-instruction `leave outer (ldloc returnVar)` block.
// one-instruction `Leave container (ldloc returnVar)` block.
if (earlyBlock.Instructions.Count != 2)
return false;
if (!earlyBlock.Instructions[0].MatchStLoc(out returnVar, out var rvValue))
if (!earlyBlock.Instructions[0].MatchStLoc(out var returnVar, out var rvValue))
return false;
if (!rvValue.MatchLdLoc(out captureVar))
return false;
if (!earlyBlock.Instructions[1].MatchBranch(out leaveBlock))
return false;
if (leaveBlock.Parent != container)
if (!earlyBlock.Instructions[1].MatchBranch(out var leaveBlock))
return false;
if (leaveBlock.Instructions.Count != 1)
return false;
if (leaveBlock.Instructions[0] is not Leave finalLeave)
return false;
if (!finalLeave.IsLeavingFunction)
if (!IsLeaveToContainerOrAncestor(finalLeave, container))
return false;
if (!finalLeave.Value.MatchLdLoc(returnVar))
return false;
leaveTargetContainer = finalLeave.TargetContainer;
return true;
}
// True when `leave` exits the immediate `container` or any of its ancestor BlockContainers.
// Self-leave (TargetContainer == container) is required for the top-level return case where
// the early-return leaves the function body container itself.
static bool IsLeaveToContainerOrAncestor(Leave leave, BlockContainer container)
{
if (leave.TargetContainer == container)
return true;
return container.IsDescendantOf(leave.TargetContainer);
}
static void ReplaceDispatchIdiomWithRethrow(Block block, ILVariable handlerVariable, ILTransformContext context)
{
// Reuse AwaitInCatchTransform.MatchExceptionCaptureBlock through the block-tail shape:

Loading…
Cancel
Save