Exploratory testing of the runtime-async branch surfaced several gaps in the RuntimeAsync* pretty-test pipeline. The full Async / RuntimeAsync test set is green today, but the matchers introduced in RuntimeAsyncExceptionRewriteTransform are narrowly fitted to the shapes the existing fixtures exercise. Patterns just outside that envelope produce raw lowered IL in the decompiled output.
All findings reproduce on the runtime-async branch (post-review HEAD). They are runtime-async specific (corresponding state-machine Async tests pass), except where noted.
Cluster 1 — Flag-based early-return matcher (TryRewriteFlagBasedEarlyReturn)
The matcher is too narrowly fitted. Four distinct failing shapes:
-
Direct return X; inside try-finally with await in finally.
The matcher requires parentBlock.Instructions[tryFinallyIdx + 1] is Branch br (i.e. a br checkBlock to a dedicated check-block). In the simplest case the compiler emits the flag check inline: directly after the TryFinally, parentBlock has if (flag == 1) leave function (capture); throw null (the IfInstruction is the next sibling, not preceded by a br).
public async Task<int> ReturnFromTryFinally()
{
try { return 42; }
finally { await Task.CompletedTask; }
}
Decompiled output retains int num; int result; ...; if (num == 1) return result; throw null;.
-
Void return; — same gate, value-less form. Same diagnosis as 1.
-
Nested try-finally with return in inner try.
The flag-setter block in the outer try is no longer 2 instructions (stloc flag(K); leave try) — it has a preceding capture-forwarding store (stloc result(innerCapture); stloc outerFlag(K); leave outer-try). The strict b.Instructions.Count == 2 gate in the flag-setter scan rejects it.
public async Task<int> ReturnFromInsideNestedTryFinally()
{
try
{
try { return 42; }
finally { await Task.CompletedTask; }
}
finally { await Task.CompletedTask; }
}
-
break / continue inside loop inside try-finally with await.
Runtime-async applies the capture-and-flag-then-leave pattern not just to return but to any non-local jump that needs to run the finally first. directLeave.IsLeavingFunction rejects loop-level leaves, so the flag machinery survives.
for (int i = 0; i < n; i++)
{
try
{
if (i == 1) continue;
if (i == 5) break;
}
finally { await Task.CompletedTask; }
}
Proposed fix: generalise the matcher.
- Accept either a
Branch to a dedicated check-block or an inline IfInstruction immediately after the TryFinally (closes 1, 2).
- Relax the flag-setter shape to "ends with
stloc flagVar(K); leave try", allowing arbitrary capture-forwarding stores before it (closes 3).
- Replace
directLeave.IsLeavingFunction with "leaves any ancestor container" and synthesise the appropriate Leave to that container — the AST builder renders break / continue / return based on the target (closes 4).
Cluster 2 — Multi-handler matcher (TryRewriteMultiHandlerTryCatch)
The matcher succeeds for LoadsToCatch's specific shape (three Exception-typed handlers, all with throw; in their bodies, uniform variable access) but fails for any heterogeneous variant:
// Fails: two different typed catches
try { await t; }
catch (InvalidOperationException ex)
{
await Task.Yield();
Console.WriteLine(ex.Message);
}
catch (ArgumentException ex)
{
await Task.Yield();
Console.WriteLine(ex.Message);
}
// Also fails: typed catch + untyped catch
try { await t; }
catch (InvalidOperationException ex) { await Task.Yield(); Console.WriteLine(ex.Message); }
catch { await Task.Yield(); Console.WriteLine("other"); }
// Also fails: typed catch + Exception with throw;
try { await t; }
catch (InvalidOperationException ex) { await Task.Yield(); Console.WriteLine(ex.Message); }
catch (Exception) { await Task.Yield(); throw; }
All three produce raw lowered output with the flag-dispatched switch (num) post-catch handling intact, plus the ExceptionDispatchInfo.Capture(...).Throw() idiom where a throw; was the source.
Proposed investigation: add ILAst-level logging to identify which handler trips up the matcher, then relax the per-handler shape constraint to accept heterogeneous prefix-store sequences. Most likely the issue is the prefix-store pattern differing per handler (e.g. cast-class store layout differs when one handler accesses the variable and the other doesn't).
Cluster 3 — "Must be last instruction" Run-loop gate
// At the top of the Run-loop, before any per-tryCatch matcher:
if (tryCatch.ChildIndex != parentBlock.Instructions.Count - 1)
continue;
This rejects any try-catch that isn't the last instruction in its parent block. For a top-level try-catch followed by an implicit function-leave it works, but for nested cases the parent block typically continues with a follow-up instruction (br continuation or similar), so the gate rejects.
Concrete reproducer:
public async Task TryCatchFinallyAllAwait()
{
try { await Task.CompletedTask; Console.WriteLine("try"); }
catch (Exception) { await Task.CompletedTask; Console.WriteLine("catch"); }
finally { await Task.CompletedTask; Console.WriteLine("finally"); }
}
The outer try-finally recovers, but the inner try-catch (now inside the outer try block) is no longer the last instruction in its parent block. The gate rejects, leaving the flag-based catch dispatch intact.
Proposed fix: drop the gate. Each matcher already verifies the post-catch successor shape via its own parentBlock.Instructions[tryCatch.ChildIndex + 1]-style checks; this Run-loop-level gate is over-restrictive. Alternatively run the rewriter iteratively to a fixed point so each level is matched in turn.
Cluster 4 — Pre-existing bug exposed (also fails on baseline)
throw as the only exit from try body:
public async Task ThrowInsideTryFinally()
{
try { throw new InvalidOperationException(); }
finally { await Task.Yield(); }
}
The try body has no outward Branch / Leave (just a throw). The existence check seenExit (and the pre-review existence check) rejects, leaving the raw lowered output. Confirmed to fail on the branch tip prior to this review as well — not a regression, but the same pattern of "too-strict matcher" applies.
Proposed fix: drop the seenExit requirement entirely. The other gates (dispatch idiom recognition, catch-handler shape, stloc obj(ldnull) pre-init) are sufficient to identify the lowered pattern; a try body whose only exit is a throw is a legitimate input.
Out of scope (general decompiler limitations, not runtime-async specific)
These failed in the exploration but fail equivalently on the state-machine Async pipeline, so they're pre-existing decompiler limitations rather than runtime-async bugs:
- ternary
?: with awaits in both arms — decompiled as if/return.
is T name pattern inside when filter — produces a BlockContainer the expression-builder can't render.
checked { ... } block decompiled as inline checked(...).
- 3+ awaits in single arithmetic expression on non-Optimize — split into intermediate locals.
Tests that pass and could be added as coverage (no bugs)
These pass today but weren't in the fixtures — worth adding as regression guards:
- consecutive top-level try-finally
async ValueTask<T> (smoke test)
async Task<(int, int)> returning tuple from awaited task
- direct
return await foo();
- true
throw; rethrow after await
- typed catch with await + variable use
catch (T) when (...) with no variable name
- switch with awaits in cases inside try-finally
- nested try-catch inside catch
- try-finally inside catch
- multiple consecutive awaits in finally
- return in try-catch (no finally)
Exploratory testing of the
runtime-asyncbranch surfaced several gaps in the RuntimeAsync* pretty-test pipeline. The full Async / RuntimeAsync test set is green today, but the matchers introduced inRuntimeAsyncExceptionRewriteTransformare narrowly fitted to the shapes the existing fixtures exercise. Patterns just outside that envelope produce raw lowered IL in the decompiled output.All findings reproduce on the runtime-async branch (post-review HEAD). They are runtime-async specific (corresponding state-machine
Asynctests pass), except where noted.Cluster 1 — Flag-based early-return matcher (
TryRewriteFlagBasedEarlyReturn)The matcher is too narrowly fitted. Four distinct failing shapes:
Direct
return X;inside try-finally with await in finally.The matcher requires
parentBlock.Instructions[tryFinallyIdx + 1] is Branch br(i.e. abr checkBlockto a dedicated check-block). In the simplest case the compiler emits the flag check inline: directly after the TryFinally,parentBlockhasif (flag == 1) leave function (capture); throw null(theIfInstructionis the next sibling, not preceded by abr).Decompiled output retains
int num; int result; ...; if (num == 1) return result; throw null;.Void
return;— same gate, value-less form. Same diagnosis as 1.Nested try-finally with
returnin inner try.The flag-setter block in the outer try is no longer 2 instructions (
stloc flag(K); leave try) — it has a preceding capture-forwarding store (stloc result(innerCapture); stloc outerFlag(K); leave outer-try). The strictb.Instructions.Count == 2gate in the flag-setter scan rejects it.break/continueinside loop inside try-finally with await.Runtime-async applies the capture-and-flag-then-leave pattern not just to
returnbut to any non-local jump that needs to run the finally first.directLeave.IsLeavingFunctionrejects loop-level leaves, so the flag machinery survives.Proposed fix: generalise the matcher.
Branchto a dedicated check-block or an inlineIfInstructionimmediately after the TryFinally (closes 1, 2).stloc flagVar(K); leave try", allowing arbitrary capture-forwarding stores before it (closes 3).directLeave.IsLeavingFunctionwith "leaves any ancestor container" and synthesise the appropriateLeaveto that container — the AST builder rendersbreak/continue/returnbased on the target (closes 4).Cluster 2 — Multi-handler matcher (
TryRewriteMultiHandlerTryCatch)The matcher succeeds for
LoadsToCatch's specific shape (three Exception-typed handlers, all withthrow;in their bodies, uniform variable access) but fails for any heterogeneous variant:All three produce raw lowered output with the flag-dispatched
switch (num)post-catch handling intact, plus theExceptionDispatchInfo.Capture(...).Throw()idiom where athrow;was the source.Proposed investigation: add ILAst-level logging to identify which handler trips up the matcher, then relax the per-handler shape constraint to accept heterogeneous prefix-store sequences. Most likely the issue is the prefix-store pattern differing per handler (e.g. cast-class store layout differs when one handler accesses the variable and the other doesn't).
Cluster 3 — "Must be last instruction" Run-loop gate
This rejects any try-catch that isn't the last instruction in its parent block. For a top-level try-catch followed by an implicit function-leave it works, but for nested cases the parent block typically continues with a follow-up instruction (
br continuationor similar), so the gate rejects.Concrete reproducer:
The outer try-finally recovers, but the inner try-catch (now inside the outer try block) is no longer the last instruction in its parent block. The gate rejects, leaving the flag-based catch dispatch intact.
Proposed fix: drop the gate. Each matcher already verifies the post-catch successor shape via its own
parentBlock.Instructions[tryCatch.ChildIndex + 1]-style checks; this Run-loop-level gate is over-restrictive. Alternatively run the rewriter iteratively to a fixed point so each level is matched in turn.Cluster 4 — Pre-existing bug exposed (also fails on baseline)
throwas the only exit from try body:The try body has no outward Branch / Leave (just a
throw). The existence checkseenExit(and the pre-review existence check) rejects, leaving the raw lowered output. Confirmed to fail on the branch tip prior to this review as well — not a regression, but the same pattern of "too-strict matcher" applies.Proposed fix: drop the
seenExitrequirement entirely. The other gates (dispatch idiom recognition, catch-handler shape,stloc obj(ldnull)pre-init) are sufficient to identify the lowered pattern; a try body whose only exit is a throw is a legitimate input.Out of scope (general decompiler limitations, not runtime-async specific)
These failed in the exploration but fail equivalently on the state-machine
Asyncpipeline, so they're pre-existing decompiler limitations rather than runtime-async bugs:?:with awaits in both arms — decompiled asif/return.is T namepattern insidewhenfilter — produces a BlockContainer the expression-builder can't render.checked { ... }block decompiled as inlinechecked(...).Tests that pass and could be added as coverage (no bugs)
These pass today but weren't in the fixtures — worth adding as regression guards:
async ValueTask<T>(smoke test)async Task<(int, int)>returning tuple from awaited taskreturn await foo();throw;rethrow after awaitcatch (T) when (...)with no variable name