Skip to content

Fix compiled generator/async-generator try/catch routing (#628, #632); lock #617, #543#683

Merged
nickna merged 1 commit into
mainfrom
wrk/elegant-kowalevski-7f0d62
Jun 16, 2026
Merged

Fix compiled generator/async-generator try/catch routing (#628, #632); lock #617, #543#683
nickna merged 1 commit into
mainfrom
wrk/elegant-kowalevski-7f0d62

Conversation

@nickna

@nickna nickna commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes a cluster of compiled-mode generator / async-generator try/catch bugs, all in the flag-based state-machine emission used when a yield/await crosses a protected region. The interpreter already handled every case; these bring compiled mode to parity with Node.

Issue Status Mode
#632 — a throw/rethrow from inside a catch/finally body isn't caught by an enclosing flag-based try Fixed sync + async
#628 — throwing/rejecting null/undefined into a flag-based try skips the catch Fixed async (the analog of #619)
#617 — a rejected await inside a try isn't caught by that try's catch Already fixed on main; locked with tests both
#543instanceof TypeError is false for an error caught inside a compiled generator body Already fixed on main (compiled); locked with tests compiled

#632 — handler-body throw routing (the substantive fix)

EmitThrow routed a handler-body throw only through enclosing finally frames and then did a real IL throw, which bypasses an outer flag-based try's caughtException/present flag — so the outer catch never ran and the exception escaped MoveNext/MoveNextAsync.

The fix routes the value into the enclosing flag-based try's capture local + present flag and branches to its cleanup, running the finally(s) strictly inside that try first — a new EmitThrowIntoEnclosingTry, the catch-side analog of the existing return/throw finally routing.

  • The enclosing try is the one already tracked by _tryBodyContext (sync) / _currentTryExceptionLocal et al. (async): these are saved before a try body and restored before its catch/finally, so while emitting a handler they identify the enclosing try.
  • A new ScopeDepth (= _exitScopes.Count at try-body start) + FinallyFramesInside computes the intervening-finally chain correctly even when the enclosing try has no finally of its own (so no stack marker).
  • Applied at three propagation sites per emitter: the lexical EmitThrow handler case, the external it.throw() injection (EmitRoutedThrow), and the catch-less post-finally rethrow (an uncaught exception leaving a try/finally must reach an enclosing flag-based catch, not escape the state machine).
  • Chain-empty → store directly; chain-nonempty (a finally may yield/await) → hold the value in <>pendingException across the finallys and move it via a routing terminal.

#628 — present-flag gate (async analog of #619)

A thrown/rejected null/undefined captures as a null CLR reference, which the old value-nullness Brfalse gate misread as "no exception". Ported the dedicated exceptionPresentLocal boolean gate (catch gate, segment-skip, post-finally rethrow) into AsyncGeneratorMoveNextEmitter; the #617 await-rejection routing now sets it too.

#617 / #543 — already fixed, now locked

Both behave correctly on main but the issues were never closed and lacked targeted coverage. Added regression tests (new GeneratorErrorIdentityTests for #543; the compiled re-entrancy test now asserts instanceof TypeError in-body) and updated the stale #543 deferral comment.

Gaps found and filed (out of scope here)

Testing

Closes #628, closes #632, closes #617, closes #543.

…; lock #617, #543 with tests

#632 (sync + async): a throw/rethrow escaping a catch/finally body did a real IL throw that
bypassed an enclosing flag-based try's caughtException flag, so the outer catch never ran and the
exception escaped the state machine. Route the value into the enclosing flag-based try's capture
local + present flag and branch to its cleanup — running the finally(s) strictly inside that try
first — via a new EmitThrowIntoEnclosingTry helper (the catch-side analog of the existing finally
routing). The enclosing try is the one already tracked by _tryBodyContext / _currentTryExceptionLocal
(saved before a try body, restored before its catch/finally, so during a handler it identifies the
enclosing try). A new ScopeDepth field + FinallyFramesInside computes the intervening-finally chain
even when that try has no finally of its own. Applied at all three propagation sites per emitter:
the lexical EmitThrow handler case, the external throw() injection (EmitRoutedThrow), and the
catch-less post-finally rethrow (an uncaught exception leaving a try/finally must reach an enclosing
flag-based catch, not escape MoveNext). Chain-empty stores directly; chain-nonempty (a finally may
yield/await) holds the value in <>pendingException and moves it via a routing terminal.

#628 (async analog of #619): port the exceptionPresentLocal boolean gate to
AsyncGeneratorMoveNextEmitter. A thrown/rejected null/undefined captures as a null CLR reference,
which the value-nullness Brfalse gate misread as "no exception", skipping the catch and dropping the
post-finally rethrow. The present flag records presence independent of the value; the #617
await-rejection routing now sets it too.

#617 and the compiled side of #543 were already fixed on main (the issues were stale/unclosed):
#617's rejected-await-in-try routing and #543's generator catch identity both behave correctly.
Add regression tests that lock the behavior — new GeneratorErrorIdentityTests for #543, and the
compiled re-entrancy test now asserts `instanceof TypeError` in-body.

Found and filed two out-of-scope gaps: #675 (a real exception escaping a nested real-IL try/catch
inside a flag-based catch body still escapes an enclosing flag-based try — a distinct mechanism, not
a direct handler throw) and #676 (interpreter property access on undefined throws a raw "Runtime
Error" string instead of a guest TypeError).

Tests: full SharpTS.Tests suite green (12714 passed). New cross-mode regression tests in
GeneratorTryFinallyTests (#632), AsyncGeneratorTryFinallyTests (#628 + #632, CompiledOnly per the
#564 eager-drain ordering), GeneratorErrorIdentityTests (#543), plus IL-verification guards.

Closes #628, closes #632, closes #617, closes #543.
@nickna nickna merged commit 3b699d7 into main Jun 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment