Skip to content

runtime-async: pretty-test gaps in exception/try-finally rewriting #3745

@siegfriedpammer

Description

@siegfriedpammer

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:

  1. 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;.

  2. Void return; — same gate, value-less form. Same diagnosis as 1.

  3. 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; }
    }
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions