diff --git a/Compilation/AsyncGeneratorMoveNextEmitter.Expressions.cs b/Compilation/AsyncGeneratorMoveNextEmitter.Expressions.cs
index 47314a1c..997117d5 100644
--- a/Compilation/AsyncGeneratorMoveNextEmitter.Expressions.cs
+++ b/Compilation/AsyncGeneratorMoveNextEmitter.Expressions.cs
@@ -540,6 +540,10 @@ protected override void EmitAwait(Expr.Await a)
_il.BeginCatchBlock(typeof(Exception));
_il.Emit(OpCodes.Call, _ctx!.Runtime!.WrapException);
_il.Emit(OpCodes.Stloc, _currentTryExceptionLocal);
+ // Record presence with the flag, not the value, so a rejected null/undefined still engages
+ // the catch rather than reading as "no exception" (#628).
+ _il.Emit(OpCodes.Ldc_I4_1);
+ _il.Emit(OpCodes.Stloc, _currentTryExceptionPresentLocal!);
_il.Emit(OpCodes.Leave, _currentTryCleanupLabel); // skip the rest of the try body → catch/finally
_il.EndExceptionBlock();
_il.Emit(OpCodes.Ldloc, resultTemp); // normal path: push the awaited value
diff --git a/Compilation/AsyncGeneratorMoveNextEmitter.Statements.TryCatch.cs b/Compilation/AsyncGeneratorMoveNextEmitter.Statements.TryCatch.cs
index 50e75108..46cd19e6 100644
--- a/Compilation/AsyncGeneratorMoveNextEmitter.Statements.TryCatch.cs
+++ b/Compilation/AsyncGeneratorMoveNextEmitter.Statements.TryCatch.cs
@@ -196,14 +196,24 @@ protected override void EmitContinue(Stmt.Continue c)
}
///
- /// A throw in a catch or finally body must run the enclosing finally(s) before the
- /// exception propagates. A throw in a try body is captured by its sync-segment mini try/catch
- /// (handled by the catch arm), so it is not routed here.
+ /// A throw in a catch or finally body propagates to the enclosing flag-based try (the one
+ /// whose body lexically contains this handler): it runs the finally(s) inside that try, then lands
+ /// in its catch — rather than a real IL throw that bypasses the flag-based catch (#632). With
+ /// no enclosing flag-based try it runs the active finally(s) and propagates out of MoveNextAsync. A
+ /// throw in a try body is captured by its sync-segment mini try/catch, not here.
///
protected override void EmitThrow(Stmt.Throw t)
{
if (_inHandlerBody && _protectedRegionDepth == 0)
{
+ if (_currentTryExceptionLocal != null)
+ {
+ EmitThrowIntoEnclosingTry(() => { EmitExpression(t.Value); EnsureBoxed(); });
+ return;
+ }
+
+ // No enclosing flag-based try (this handler belongs to the outermost try), but its own
+ // finally may still be active and must run before the throw leaves MoveNextAsync.
var chain = ActiveFinallyFrames();
if (chain.Count > 0)
{
@@ -224,6 +234,49 @@ protected override void EmitThrow(Stmt.Throw t)
base.EmitThrow(t);
}
+ ///
+ /// Propagates a guest exception escaping a handler body into the enclosing flag-based try (tracked
+ /// by et al. while emitting a catch/finally body): stores
+ /// the value into that try's capture local and sets its present flag, then branches to its cleanup
+ /// entry so its catch runs. Any finally(s) strictly inside that try run first; because such a
+ /// finally can yield/await, the value is held in <>pendingException across them and
+ /// moved into the capture local by the routing terminal (#632, async analog).
+ ///
+ private void EmitThrowIntoEnclosingTry(Action loadValue)
+ {
+ var enclException = _currentTryExceptionLocal!;
+ var enclPresent = _currentTryExceptionPresentLocal!;
+ var enclCleanup = _currentTryCleanupLabel;
+ var chain = FinallyFramesInside(_currentTryScopeDepth);
+ if (chain.Count == 0)
+ {
+ // No intervening finally: store straight into the enclosing try and branch to its catch.
+ loadValue();
+ _il.Emit(OpCodes.Stloc, enclException);
+ _il.Emit(OpCodes.Ldc_I4_1);
+ _il.Emit(OpCodes.Stloc, enclPresent);
+ _il.Emit(OpCodes.Br, enclCleanup);
+ return;
+ }
+
+ // Intervening finally(s) may yield/await, so hold the value in a field across them; the routing
+ // terminal moves it into the enclosing try's capture local and branches to its catch.
+ _il.Emit(OpCodes.Ldarg_0);
+ loadValue();
+ _il.Emit(OpCodes.Stfld, GetPendingExceptionField());
+ int code = _nextExitCode++;
+ _exitTerminals[code] = () =>
+ {
+ _il.Emit(OpCodes.Ldarg_0);
+ _il.Emit(OpCodes.Ldfld, GetPendingExceptionField());
+ _il.Emit(OpCodes.Stloc, enclException);
+ _il.Emit(OpCodes.Ldc_I4_1);
+ _il.Emit(OpCodes.Stloc, enclPresent);
+ _il.Emit(OpCodes.Br, enclCleanup);
+ };
+ RouteThroughFinallys(chain, code, OpCodes.Br);
+ }
+
// ---- Routing helpers ------------------------------------------------------------------------
/// All open finally scopes, innermost first.
@@ -249,6 +302,21 @@ private List FinallyFramesAbove(LoopScope target)
return result;
}
+ ///
+ /// The finally scopes strictly inside the flag-based try whose body began at (= _exitScopes.Count at that point), innermost first. Excludes the
+ /// try's own finally (just below scopeDepth) and everything outside it — the finallys a throw
+ /// escaping a nested handler must run before reaching that try's catch (#632).
+ ///
+ private List FinallyFramesInside(int scopeDepth)
+ {
+ var result = new List();
+ for (int i = _exitScopes.Count - 1; i >= scopeDepth; i--)
+ if (_exitScopes[i] is FinallyScope fs)
+ result.Add(fs);
+ return result;
+ }
+
///
/// Records 's per-frame routing across (each frame
/// chains to the next, the outermost is terminal), then sets the pending-exit field and branches to
@@ -440,12 +508,20 @@ private void StoreCaughtExceptionToParam(string name)
private void EmitTryCatchWithSuspensions(Stmt.TryCatch t)
{
var caughtExceptionLocal = _il.DeclareLocal(typeof(object));
+ // Whether the try body raised an exception, tracked separately from caughtExceptionLocal's
+ // nullness: a thrown/rejected null/undefined captures as a null CLR reference, which a
+ // value-nullness gate misreads as "no exception" — skipping the catch and dropping the
+ // post-finally rethrow (#628, the async analog of #619). This flag records presence regardless
+ // of the captured value.
+ var exceptionPresentLocal = _il.DeclareLocal(typeof(bool));
var afterTryBodyLabel = _il.DefineLabel();
var afterTryCatchLabel = _il.DefineLabel();
// No exception captured yet.
_il.Emit(OpCodes.Ldnull);
_il.Emit(OpCodes.Stloc, caughtExceptionLocal);
+ _il.Emit(OpCodes.Ldc_I4_0);
+ _il.Emit(OpCodes.Stloc, exceptionPresentLocal);
// Two finally-running mechanisms share `afterTryBodyLabel` as the cleanup entry:
// 1. External `generator.return()`: a suspended yield observes `__returnRequested` on resume
@@ -469,14 +545,22 @@ private void EmitTryCatchWithSuspensions(Stmt.TryCatch t)
_inHandlerBody = false;
// Carry this try's capture target down to the suspension points emitted at the top level, so a
// rejected `await` inside the try routes its exception into the same flag + catch/finally
- // instead of escaping MoveNextAsync (#617). Saved/restored for correct nesting.
+ // instead of escaping MoveNextAsync (#617), setting the present flag so a null rejection still
+ // engages the catch (#628). After the body these revert to the enclosing try, which a throw
+ // escaping this try's catch/finally must route to (#632). Saved/restored for correct nesting.
var previousTryExceptionLocal = _currentTryExceptionLocal;
+ var previousTryExceptionPresentLocal = _currentTryExceptionPresentLocal;
var previousTryCleanupLabel = _currentTryCleanupLabel;
+ var previousTryScopeDepth = _currentTryScopeDepth;
_currentTryExceptionLocal = caughtExceptionLocal;
+ _currentTryExceptionPresentLocal = exceptionPresentLocal;
_currentTryCleanupLabel = afterTryBodyLabel;
- EmitTryBodyWithSuspensions(t.TryBlock, caughtExceptionLocal, afterTryBodyLabel);
+ _currentTryScopeDepth = _exitScopes.Count;
+ EmitTryBodyWithSuspensions(t.TryBlock, caughtExceptionLocal, exceptionPresentLocal, afterTryBodyLabel);
_currentTryExceptionLocal = previousTryExceptionLocal;
+ _currentTryExceptionPresentLocal = previousTryExceptionPresentLocal;
_currentTryCleanupLabel = previousTryCleanupLabel;
+ _currentTryScopeDepth = previousTryScopeDepth;
_inHandlerBody = previousInHandler;
// Restore the enclosing try's cleanup label (its yields must route to it, not ours).
@@ -488,8 +572,10 @@ private void EmitTryCatchWithSuspensions(Stmt.TryCatch t)
// a non-local exit (including a throw) from the catch body runs this finally too.
if (t.CatchBlock != null)
{
+ // Gate on the present flag, not the value's nullness, so a caught null/undefined enters the
+ // catch (#628). The value local stays authoritative for the binding below.
var skipCatchLabel = _il.DefineLabel();
- _il.Emit(OpCodes.Ldloc, caughtExceptionLocal);
+ _il.Emit(OpCodes.Ldloc, exceptionPresentLocal);
_il.Emit(OpCodes.Brfalse, skipCatchLabel);
// Bind the captured value to the catch param, honouring a hoisted field when the param is
@@ -502,10 +588,11 @@ private void EmitTryCatchWithSuspensions(Stmt.TryCatch t)
StoreCaughtExceptionToParam(t.CatchParam.Lexeme);
}
- // Catch handles it; clear the flag so the post-finally rethrow below is skipped — and so a
- // routed exit re-entering afterTryBody skips the catch rather than re-running it.
- _il.Emit(OpCodes.Ldnull);
- _il.Emit(OpCodes.Stloc, caughtExceptionLocal);
+ // Catch handles it; clear the present flag so the post-finally rethrow below is skipped —
+ // and so a routed exit re-entering afterTryBody skips the catch rather than re-running it.
+ // The flag (not the value) is the gate now, so clearing it is what matters (#628).
+ _il.Emit(OpCodes.Ldc_I4_0);
+ _il.Emit(OpCodes.Stloc, exceptionPresentLocal);
_inHandlerBody = true;
foreach (var stmt in t.CatchBlock)
@@ -545,20 +632,31 @@ private void EmitTryCatchWithSuspensions(Stmt.TryCatch t)
// In-body non-local exit (mechanism 2): dispatch any pending exit that routed through here.
EmitFinallyDispatch(frame!);
- // Rethrow an uncaught exception once the finally has run (try/finally with no catch).
+ // Propagate an uncaught exception once the finally has run (try/finally with no catch).
if (t.CatchBlock == null)
{
+ // Gate on the present flag so a thrown/rejected null/undefined still propagates (#628).
var noExceptionLabel = _il.DefineLabel();
- _il.Emit(OpCodes.Ldloc, caughtExceptionLocal);
+ _il.Emit(OpCodes.Ldloc, exceptionPresentLocal);
_il.Emit(OpCodes.Brfalse, noExceptionLabel);
- // Mark the generator done before the exception leaves MoveNextAsync.
- _il.Emit(OpCodes.Ldarg_0);
- _il.Emit(OpCodes.Ldc_I4, -2);
- _il.Emit(OpCodes.Stfld, _builder.StateField);
- _il.Emit(OpCodes.Ldloc, caughtExceptionLocal);
- _il.Emit(OpCodes.Call, _ctx!.Runtime!.CreateException);
- _il.Emit(OpCodes.Throw);
+ if (_currentTryExceptionLocal != null)
+ {
+ // The finally has run; the still-uncaught exception now propagates to the enclosing
+ // flag-based try's catch (not out of MoveNextAsync), so an outer catch still handles
+ // it — the try/finally analog of the handler-body throw routing (#632).
+ EmitThrowIntoEnclosingTry(() => _il.Emit(OpCodes.Ldloc, caughtExceptionLocal));
+ }
+ else
+ {
+ // No enclosing flag-based try: mark done and propagate out of MoveNextAsync.
+ _il.Emit(OpCodes.Ldarg_0);
+ _il.Emit(OpCodes.Ldc_I4, -2);
+ _il.Emit(OpCodes.Stfld, _builder.StateField);
+ _il.Emit(OpCodes.Ldloc, caughtExceptionLocal);
+ _il.Emit(OpCodes.Call, _ctx!.Runtime!.CreateException);
+ _il.Emit(OpCodes.Throw);
+ }
_il.MarkLabel(noExceptionLabel);
}
@@ -571,7 +669,7 @@ private void EmitTryCatchWithSuspensions(Stmt.TryCatch t)
/// Walks the try body, wrapping runs of plain statements in mini IL try/catch blocks while
/// emitting suspension points and non-local exits (return/break/continue) at the top level.
///
- private void EmitTryBodyWithSuspensions(List tryBody, LocalBuilder caughtExceptionLocal, Label afterTryLabel)
+ private void EmitTryBodyWithSuspensions(List tryBody, LocalBuilder caughtExceptionLocal, LocalBuilder exceptionPresentLocal, Label afterTryLabel)
{
List syncSegment = [];
@@ -582,12 +680,13 @@ private void EmitTryBodyWithSuspensions(List tryBody, LocalBuilder caughtE
// Flush the accumulated plain statements first.
if (syncSegment.Count > 0)
{
- EmitSyncSegmentInTry(syncSegment, caughtExceptionLocal);
+ EmitSyncSegmentInTry(syncSegment, caughtExceptionLocal, exceptionPresentLocal);
syncSegment.Clear();
}
// If an earlier segment threw, skip the suspension/exit and head to catch/finally.
- _il.Emit(OpCodes.Ldloc, caughtExceptionLocal);
+ // Gate on the present flag so a thrown null/undefined still short-circuits here (#628).
+ _il.Emit(OpCodes.Ldloc, exceptionPresentLocal);
_il.Emit(OpCodes.Brtrue, afterTryLabel);
// Emitted at the top level: a yield/await's `ret`/resume label and a return's `ret` or a
@@ -601,18 +700,19 @@ private void EmitTryBodyWithSuspensions(List tryBody, LocalBuilder caughtE
}
if (syncSegment.Count > 0)
- EmitSyncSegmentInTry(syncSegment, caughtExceptionLocal);
+ EmitSyncSegmentInTry(syncSegment, caughtExceptionLocal, exceptionPresentLocal);
}
///
/// Emits a run of plain (non-suspending, non-exiting) statements inside a real IL try/catch
/// that records any thrown exception into .
///
- private void EmitSyncSegmentInTry(List statements, LocalBuilder caughtExceptionLocal)
+ private void EmitSyncSegmentInTry(List statements, LocalBuilder caughtExceptionLocal, LocalBuilder exceptionPresentLocal)
{
- // An earlier segment may already have thrown — don't run this one.
+ // An earlier segment may already have thrown — don't run this one. Gate on the present flag so
+ // a prior thrown null/undefined still suppresses this segment (#628).
var skipLabel = _il.DefineLabel();
- _il.Emit(OpCodes.Ldloc, caughtExceptionLocal);
+ _il.Emit(OpCodes.Ldloc, exceptionPresentLocal);
_il.Emit(OpCodes.Brtrue, skipLabel);
// A real IL protected region is open across the segment body (see _protectedRegionDepth).
@@ -624,6 +724,10 @@ private void EmitSyncSegmentInTry(List statements, LocalBuilder caughtExce
_il.BeginCatchBlock(typeof(Exception));
_il.Emit(OpCodes.Call, _ctx!.Runtime!.WrapException);
_il.Emit(OpCodes.Stloc, caughtExceptionLocal);
+ // Record presence with the flag, not the value: a caught null/undefined would otherwise read
+ // as "no exception" at the gates above (#628).
+ _il.Emit(OpCodes.Ldc_I4_1);
+ _il.Emit(OpCodes.Stloc, exceptionPresentLocal);
_il.EndExceptionBlock();
_protectedRegionDepth--;
diff --git a/Compilation/AsyncGeneratorMoveNextEmitter.cs b/Compilation/AsyncGeneratorMoveNextEmitter.cs
index ab78e57b..07cd15a0 100644
--- a/Compilation/AsyncGeneratorMoveNextEmitter.cs
+++ b/Compilation/AsyncGeneratorMoveNextEmitter.cs
@@ -44,12 +44,23 @@ public partial class AsyncGeneratorMoveNextEmitter : StatementEmitterBase
// While emitting a flag-based try BODY (EmitTryCatchWithSuspensions), these carry that try's
// exception-capture target down to suspension points, which are emitted at the top level —
// outside the sync segments' mini try/catch. A rejected `await` inside the try captures its
- // exception into _currentTryExceptionLocal (exactly as a sync segment does) and `Leave`s to
- // _currentTryCleanupLabel so the try's catch/finally run, instead of escaping MoveNextAsync
- // unhandled (#617). Null when not inside such a try body (the common case → GetResult is plain).
+ // exception into _currentTryExceptionLocal (exactly as a sync segment does), sets the present
+ // flag, and `Leave`s to _currentTryCleanupLabel so the try's catch/finally run, instead of
+ // escaping MoveNextAsync unhandled (#617). The present flag (not the value's nullness) gates the
+ // catch so a rejected `Promise.reject(null)`/`throw null` still engages it (#628, the async
+ // analog of #619). Saved/restored around the body, so during a catch/finally body these instead
+ // identify the *enclosing* flag-based try — the one whose catch must handle a throw escaping that
+ // handler (#632). Null when not inside such a try body (the common case → GetResult is plain).
private LocalBuilder? _currentTryExceptionLocal;
+ private LocalBuilder? _currentTryExceptionPresentLocal;
private Label _currentTryCleanupLabel;
+ // `_exitScopes.Count` captured at the start of the current flag-based try's body emission (after
+ // its own finally scope, if any, was pushed). Finally scopes at indices >= this are strictly
+ // *inside* that try; a throw escaping a nested handler runs exactly those before reaching this
+ // try's catch (#632). Meaningful only while _currentTryExceptionLocal is non-null.
+ private int _currentTryScopeDepth;
+
// Compilation context for access to functions, classes, etc.
private CompilationContext? _ctx;
diff --git a/Compilation/GeneratorMoveNextEmitter.Statements.TryCatch.cs b/Compilation/GeneratorMoveNextEmitter.Statements.TryCatch.cs
index 46448c0e..c128822a 100644
--- a/Compilation/GeneratorMoveNextEmitter.Statements.TryCatch.cs
+++ b/Compilation/GeneratorMoveNextEmitter.Statements.TryCatch.cs
@@ -70,14 +70,16 @@ private sealed class FinallyScope : IExitScope
private bool _inHandlerBody;
// The innermost flag-based try whose *body* is currently being emitted: its catch/finally entry
- // (afterTryBodyLabel), the local capturing a try-body exception, and the boolean flag recording
- // whether one was captured. An external throw() injected at a yield in this body behaves as if the
- // body threw there — it stores the error into the local, sets the flag, and branches to the cleanup
- // so the catch/finally run (#526). The flag (not the value's nullness) gates the catch so an
- // injected throw(null)/throw(undefined) still engages it (#619). Saved/restored around the try-body
- // emission, so it is null (or the enclosing try's) while emitting a catch/finally body, where
- // `_inHandlerBody` instead routes an injected throw through the enclosing finally(s).
- private (Label AfterTryBody, LocalBuilder CaughtException, LocalBuilder ExceptionPresent)? _tryBodyContext;
+ // (afterTryBodyLabel), the local capturing a try-body exception, the boolean flag recording whether
+ // one was captured, and `_exitScopes.Count` at the start of its body (ScopeDepth — finally scopes at
+ // indices >= it are strictly inside this try). An external throw() injected at a yield in this body
+ // behaves as if the body threw there — it stores the error into the local, sets the flag, and
+ // branches to the cleanup so the catch/finally run (#526). The flag (not the value's nullness) gates
+ // the catch so an injected throw(null)/throw(undefined) still engages it (#619). Saved/restored
+ // around the try-body emission, so while emitting a catch/finally body it instead identifies the
+ // *enclosing* flag-based try (or is null) — the one whose catch must handle a throw escaping that
+ // handler, after the finally(s) inside it have run (#632).
+ private (Label AfterTryBody, LocalBuilder CaughtException, LocalBuilder ExceptionPresent, int ScopeDepth)? _tryBodyContext;
// `<>pendingExit` (int): the code of an in-flight non-local exit, or 0 when none. A finally that
// yields suspends MoveNext mid-routing, so this must be a field (a local would reset on re-entry).
@@ -221,14 +223,24 @@ protected override void EmitContinue(Stmt.Continue c)
}
///
- /// A throw in a catch or finally body must run the enclosing finally(s) before the
- /// exception propagates. A throw in a try body is captured by its sync-segment mini try/catch
- /// (handled by the catch arm), so it is not routed here.
+ /// A throw in a catch or finally body propagates to the enclosing flag-based try (the one
+ /// whose body lexically contains this handler): it runs the finally(s) inside that try, then lands
+ /// in its catch — rather than a real IL throw that bypasses the flag-based catch (#632). With
+ /// no enclosing flag-based try it runs the active finally(s) and propagates out of MoveNext. A throw
+ /// in a try body is captured by its sync-segment mini try/catch (handled by the catch arm), not here.
///
protected override void EmitThrow(Stmt.Throw t)
{
if (_inHandlerBody && _protectedRegionDepth == 0)
{
+ if (_tryBodyContext is { } encl)
+ {
+ EmitThrowIntoEnclosingTry(encl, () => { EmitExpression(t.Value); EnsureBoxed(); });
+ return;
+ }
+
+ // No enclosing flag-based try (this handler belongs to the outermost try), but its own
+ // finally may still be active and must run before the throw leaves MoveNext.
var chain = ActiveFinallyFrames();
if (chain.Count > 0)
{
@@ -249,6 +261,47 @@ protected override void EmitThrow(Stmt.Throw t)
base.EmitThrow(t);
}
+ ///
+ /// Propagates a guest exception escaping a handler body into the enclosing flag-based try
+ /// : stores the value into that try's capture local and sets its present
+ /// flag, then branches to its cleanup entry so its catch runs (or, catch-less, its finally then its
+ /// own propagation). Any finally(s) strictly inside that try run first; because such a finally can
+ /// yield, the value is held in <>pendingException across them and moved into the capture
+ /// local by the routing terminal. This is the catch-side analog of the finally routing already used
+ /// for a routed return/throw (#632). pushes the boxed guest value.
+ ///
+ private void EmitThrowIntoEnclosingTry((Label AfterTryBody, LocalBuilder CaughtException, LocalBuilder ExceptionPresent, int ScopeDepth) encl, Action loadValue)
+ {
+ var chain = FinallyFramesInside(encl.ScopeDepth);
+ if (chain.Count == 0)
+ {
+ // No intervening finally: store straight into the enclosing try and branch to its catch.
+ loadValue();
+ _il.Emit(OpCodes.Stloc, encl.CaughtException);
+ _il.Emit(OpCodes.Ldc_I4_1);
+ _il.Emit(OpCodes.Stloc, encl.ExceptionPresent);
+ _il.Emit(OpCodes.Br, encl.AfterTryBody);
+ return;
+ }
+
+ // Intervening finally(s) may yield, so hold the value in a field across them; the routing
+ // terminal moves it into the enclosing try's capture local and branches to its catch.
+ _il.Emit(OpCodes.Ldarg_0);
+ loadValue();
+ _il.Emit(OpCodes.Stfld, GetPendingExceptionField());
+ int code = _nextExitCode++;
+ _exitTerminals[code] = () =>
+ {
+ _il.Emit(OpCodes.Ldarg_0);
+ _il.Emit(OpCodes.Ldfld, GetPendingExceptionField());
+ _il.Emit(OpCodes.Stloc, encl.CaughtException);
+ _il.Emit(OpCodes.Ldc_I4_1);
+ _il.Emit(OpCodes.Stloc, encl.ExceptionPresent);
+ _il.Emit(OpCodes.Br, encl.AfterTryBody);
+ };
+ RouteThroughFinallys(chain, code, OpCodes.Br);
+ }
+
// ---- Routing helpers ------------------------------------------------------------------------
/// All open finally scopes, innermost first.
@@ -274,6 +327,21 @@ private List FinallyFramesAbove(LoopScope target)
return result;
}
+ ///
+ /// The finally scopes strictly inside the flag-based try whose body began at (= _exitScopes.Count at that point), innermost first. Excludes the
+ /// try's own finally (which lives just below scopeDepth) and everything outside it. These are the
+ /// finallys a throw escaping a nested handler must run before reaching that try's catch (#632).
+ ///
+ private List FinallyFramesInside(int scopeDepth)
+ {
+ var result = new List();
+ for (int i = _exitScopes.Count - 1; i >= scopeDepth; i--)
+ if (_exitScopes[i] is FinallyScope fs)
+ result.Add(fs);
+ return result;
+ }
+
///
/// Records 's per-frame routing across (each frame
/// chains to the next, the outermost is terminal), then sets the pending-exit field and branches to
@@ -454,32 +522,41 @@ private void EmitRoutedReturn(Action loadValue)
}
///
- /// Emits a throw <value> at a top-level resume point. Inside a try body it behaves
- /// as if the body threw there (store into the try's caught-exception local and branch to its
- /// cleanup, so the catch/finally run); inside a catch/finally body it routes through the
- /// enclosing finally(s); with no enclosing try it propagates out of MoveNext. pushes the boxed guest error value.
+ /// Emits a throw <value> at a top-level resume point (an external throw()
+ /// injected at a suspended yield). Inside a try body it behaves as if the body threw there (store
+ /// into the try's caught-exception local and branch to its cleanup, so the catch/finally run);
+ /// inside a catch/finally body it propagates to the enclosing flag-based try's catch the same way a
+ /// lexical handler-body throw does (#632); with no enclosing try it runs the active finally(s) and
+ /// propagates out of MoveNext. pushes the boxed guest error value.
///
private void EmitRoutedThrow(Action loadValue)
{
- if (!_inHandlerBody && _tryBodyContext is { } tryBody)
+ if (_tryBodyContext is { } ctx)
{
- // In a try body: capture exactly like a try-body exception so the catch/finally at
- // afterTryBodyLabel handle it. A catch-less yielding finally persists this local to a
- // field before suspending, so it survives (#599). Set the present flag (not the value's
- // nullness) so an injected throw(null)/throw(undefined) still engages the catch (#619).
- loadValue();
- _il.Emit(OpCodes.Stloc, tryBody.CaughtException);
- _il.Emit(OpCodes.Ldc_I4_1);
- _il.Emit(OpCodes.Stloc, tryBody.ExceptionPresent);
- _il.Emit(OpCodes.Br, tryBody.AfterTryBody);
+ if (!_inHandlerBody)
+ {
+ // In a try body: capture exactly like a try-body exception so the catch/finally at
+ // afterTryBodyLabel handle it. A catch-less yielding finally persists this local to a
+ // field before suspending, so it survives (#599). Set the present flag (not the value's
+ // nullness) so an injected throw(null)/throw(undefined) still engages the catch (#619).
+ loadValue();
+ _il.Emit(OpCodes.Stloc, ctx.CaughtException);
+ _il.Emit(OpCodes.Ldc_I4_1);
+ _il.Emit(OpCodes.Stloc, ctx.ExceptionPresent);
+ _il.Emit(OpCodes.Br, ctx.AfterTryBody);
+ return;
+ }
+
+ // In a catch/finally body: run the finally(s) inside the enclosing try, then land in its
+ // catch — the injection-path analog of the lexical handler-body throw fix (#632).
+ EmitThrowIntoEnclosingTry(ctx, loadValue);
return;
}
var chain = ActiveFinallyFrames();
if (chain.Count > 0)
{
- // In a catch/finally body: run the enclosing finally(s), then rethrow at the terminal.
+ // Outermost try's catch/finally body: run its own finally(s), then rethrow at the terminal.
_il.Emit(OpCodes.Ldarg_0);
loadValue();
_il.Emit(OpCodes.Stfld, GetPendingExceptionField());
@@ -669,7 +746,7 @@ void EmitLoadExceptionPresent()
bool previousInHandler = _inHandlerBody;
var previousTryBody = _tryBodyContext;
_inHandlerBody = false;
- _tryBodyContext = (afterTryBodyLabel, caughtExceptionLocal, exceptionPresentLocal);
+ _tryBodyContext = (afterTryBodyLabel, caughtExceptionLocal, exceptionPresentLocal, _exitScopes.Count);
EmitTryBodyWithYields(t.TryBlock, caughtExceptionLocal, exceptionPresentLocal, afterTryBodyLabel);
_tryBodyContext = previousTryBody;
_inHandlerBody = previousInHandler;
@@ -737,20 +814,30 @@ void EmitLoadExceptionPresent()
EmitFinallyDispatch(frame!);
}
- // Rethrow an uncaught exception once the finally has run (try/finally with no catch).
+ // Propagate an uncaught exception once the finally has run (try/finally with no catch).
if (t.CatchBlock == null)
{
var noExceptionLabel = _il.DefineLabel();
EmitLoadExceptionPresent();
_il.Emit(OpCodes.Brfalse, noExceptionLabel);
- // Mark the generator done before the exception leaves MoveNext.
- _il.Emit(OpCodes.Ldarg_0);
- _il.Emit(OpCodes.Ldc_I4, -2);
- _il.Emit(OpCodes.Stfld, _builder.StateField);
- EmitLoadCaughtException();
- _il.Emit(OpCodes.Call, _ctx!.Runtime!.CreateException);
- _il.Emit(OpCodes.Throw);
+ if (_tryBodyContext is { } encl)
+ {
+ // The finally has run; the still-uncaught exception now propagates to the enclosing
+ // flag-based try's catch (not out of MoveNext), so an outer catch still handles it — the
+ // try/finally analog of the handler-body throw routing (#632).
+ EmitThrowIntoEnclosingTry(encl, EmitLoadCaughtException);
+ }
+ else
+ {
+ // No enclosing flag-based try: mark the generator done and propagate out of MoveNext.
+ _il.Emit(OpCodes.Ldarg_0);
+ _il.Emit(OpCodes.Ldc_I4, -2);
+ _il.Emit(OpCodes.Stfld, _builder.StateField);
+ EmitLoadCaughtException();
+ _il.Emit(OpCodes.Call, _ctx!.Runtime!.CreateException);
+ _il.Emit(OpCodes.Throw);
+ }
_il.MarkLabel(noExceptionLabel);
}
diff --git a/SharpTS.Tests/SharedTests/AsyncGeneratorTryFinallyTests.cs b/SharpTS.Tests/SharedTests/AsyncGeneratorTryFinallyTests.cs
index 61a8e460..0d2e5162 100644
--- a/SharpTS.Tests/SharedTests/AsyncGeneratorTryFinallyTests.cs
+++ b/SharpTS.Tests/SharedTests/AsyncGeneratorTryFinallyTests.cs
@@ -527,6 +527,138 @@ public void CatchParamAfterAwaitInCatchBody_PreservesValue(ExecutionMode mode)
Assert.Equal("v1\ncaught boom\nv9\n", TestHarness.Run(source, mode));
}
+ // ---- #628: throwing/rejecting null/undefined into a flag-based try/catch must engage the catch ----
+ // The async analog of #619: the flag-based scheme inferred "was an exception thrown?" from the
+ // captured value's nullness, so a thrown/rejected null/undefined (a null CLR reference) read as "no
+ // exception" — skipping the catch. A dedicated present flag now records presence independent of the
+ // value, set by both the sync-segment capture and the rejected-await routing (#617). CompiledOnly:
+ // the interpreter's eager-drain ordering (#564) is a separate concern.
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void ThrowNullIntoFlagBasedTryCatch_IsCaught(ExecutionMode mode)
+ {
+ // The exact #628 repro: throw null after a yield, caught in the same try's catch.
+ var source = """
+ async function* g() { try { yield 1; throw null; } catch (e) { console.log("caught isNull=" + (e === null)); } }
+ async function main() { for await (const v of g()) console.log("v" + v); }
+ main();
+ """;
+
+ Assert.Equal("v1\ncaught isNull=true\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void ThrowUndefinedIntoFlagBasedTryCatch_IsCaught(ExecutionMode mode)
+ {
+ // throw undefined likewise reaches the catch (was skipped). Asserted via `=== undefined`.
+ var source = """
+ async function* g() { try { yield 1; throw undefined; } catch (e) { console.log("isUndef=" + (e === undefined)); } }
+ async function main() { for await (const v of g()) console.log("v" + v); }
+ main();
+ """;
+
+ Assert.Equal("v1\nisUndef=true\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void RejectedNullAwaitInTry_IsCaughtWithNullBinding(ExecutionMode mode)
+ {
+ // A rejected await whose reason is null must reach the catch (the await-routing present flag,
+ // #617 + #628), and the catch param must bind the null reason.
+ var source = """
+ async function* g() { try { await Promise.reject(null); } catch (e) { console.log("isNull=" + (e === null)); } yield 1; }
+ async function main() { for await (const v of g()) console.log("v" + v); }
+ main();
+ """;
+
+ Assert.Equal("isNull=true\nv1\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void FalsyNonNullThrowIntoFlagBasedTryCatch_StillCaught(ExecutionMode mode)
+ {
+ // Boundary guard: a falsy-but-non-null thrown value (0) boxes to a non-null reference and was
+ // already caught; it must remain so under the present-flag scheme.
+ var source = """
+ async function* g() { try { yield 1; throw 0; } catch (e) { console.log("caught e=" + e); } }
+ async function main() { for await (const v of g()) console.log("v" + v); }
+ main();
+ """;
+
+ Assert.Equal("v1\ncaught e=0\n", TestHarness.Run(source, mode));
+ }
+
+ // ---- #632: a throw escaping a handler body must reach an enclosing flag-based try's catch ----
+ // Async analog of the plain-generator #632: a handler-body throw is routed into the enclosing
+ // flag-based try's capture local (running the finally(s) inside that try first) and branched to its
+ // cleanup, instead of a real IL throw that bypasses the flag-based catch.
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void RethrowFromCatch_CaughtByEnclosingTryCatch(ExecutionMode mode)
+ {
+ // The exact async #632 repro: a rejected await is caught by the inner catch, which rethrows; the
+ // outer catch must catch the rethrown value.
+ var source = """
+ async function* g() {
+ try {
+ try { await Promise.reject("inner"); } catch (e: any) { console.log("inner caught " + e); throw "rethrown"; }
+ } catch (e: any) { console.log("outer caught " + e); }
+ yield 1;
+ }
+ async function main() { for await (const v of g()) console.log("v" + v); }
+ main();
+ """;
+
+ Assert.Equal("inner caught inner\nouter caught rethrown\nv1\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void RethrowFromCatch_WithInterveningFinally_RunsFinallyThenOuterCatch(ExecutionMode mode)
+ {
+ // The inner try has a finally: a throw escaping the inner catch runs that finally before the
+ // outer catch sees the value.
+ var source = """
+ async function* g() {
+ try {
+ try { yield 0; throw "inner"; }
+ catch (e: any) { console.log("C1 " + e); throw "rethrown"; }
+ finally { console.log("F1"); }
+ } catch (e: any) { console.log("outer " + e); }
+ yield 1;
+ }
+ async function main() { for await (const v of g()) console.log("v" + v); }
+ main();
+ """;
+
+ Assert.Equal("v0\nC1 inner\nF1\nouter rethrown\nv1\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void UncaughtThrowFromCatchlessTryFinally_CaughtByEnclosingTry(ExecutionMode mode)
+ {
+ // An uncaught exception leaving a catch-less inner try/finally must, after its finally runs,
+ // propagate to the enclosing flag-based catch rather than escape the state machine.
+ var source = """
+ async function* g() {
+ try {
+ try { yield 0; throw "x"; } finally { console.log("F"); }
+ } catch (e: any) { console.log("outer " + e); }
+ yield 1;
+ }
+ async function main() { for await (const v of g()) console.log("v" + v); }
+ main();
+ """;
+
+ Assert.Equal("v0\nF\nouter x\nv1\n", TestHarness.Run(source, mode));
+ }
+
// ---- IL-verification guards (the heart of #559: emitted IL must verify) ----
[Theory]
@@ -563,6 +695,13 @@ public void CatchParamAfterAwaitInCatchBody_PreservesValue(ExecutionMode mode)
// #569: catch parameter read after a suspension (yield / await) in the catch body — hoisted-field binding.
[InlineData("async function* g() { try { yield 1; throw 'boom'; } catch (e) { yield 0; console.log(e); } } async function main(){ for await (const v of g()) {} } main();")]
[InlineData("async function* g() { try { yield 1; throw 'boom'; } catch (e) { await Promise.resolve(0); console.log(e); } } async function main(){ for await (const v of g()) {} } main();")]
+ // #628: thrown/rejected null/undefined into a flag-based try — the present-flag gates must verify.
+ [InlineData("async function* g() { try { yield 1; throw null; } catch (e) { console.log(e); } } async function main(){ for await (const v of g()) {} } main();")]
+ [InlineData("async function* g() { try { await Promise.reject(null); } catch (e) { console.log(e); } yield 1; } async function main(){ for await (const v of g()) {} } main();")]
+ // #632: a throw/rethrow escaping a handler routed into an enclosing flag-based try's catch.
+ [InlineData("async function* g() { try { try { yield 0; throw 'a'; } catch (e) { throw 'b'; } } catch (e) { console.log(e); } yield 1; } async function main(){ for await (const v of g()) {} } main();")]
+ [InlineData("async function* g() { try { try { yield 0; throw 'a'; } catch (e) { throw 'b'; } finally { console.log('f'); } } catch (e) { console.log(e); } } async function main(){ for await (const v of g()) {} } main();")]
+ [InlineData("async function* g() { try { try { yield 0; throw 'x'; } finally { yield 7; } } catch (e) { console.log(e); } } async function main(){ for await (const v of g()) {} } main();")]
public void AsyncGeneratorTryFinallyWithSuspension_EmitsVerifiableIL(string source)
{
var errors = TestHarness.CompileAndVerifyOnly(source);
diff --git a/SharpTS.Tests/SharedTests/GeneratorErrorIdentityTests.cs b/SharpTS.Tests/SharedTests/GeneratorErrorIdentityTests.cs
new file mode 100644
index 00000000..85c0bf19
--- /dev/null
+++ b/SharpTS.Tests/SharedTests/GeneratorErrorIdentityTests.cs
@@ -0,0 +1,74 @@
+using SharpTS.Tests.Infrastructure;
+using Xunit;
+
+namespace SharpTS.Tests.SharedTests;
+
+///
+/// Regression tests for #543: an error caught by a try/catch inside a compiled
+/// generator body must satisfy instanceof, not merely carry the right .name/.message.
+/// The catch binding in the generator's MoveNext previously left the caught value as something
+/// whose prototype chain failed the instanceof check while the structural fields still read
+/// correctly. The compiled path now produces a real error object in both the immediate and the
+/// flag-based (post-yield) try shapes.
+///
+///
+/// The runtime "not a function" cases are CompiledOnly: the interpreter has a separate, broader gap —
+/// a property access / call on undefined throws a raw host "Runtime Error" string rather than a
+/// guest TypeError, even at top level outside any generator — tracked in #676. Explicitly thrown
+/// errors carry correct identity in both modes and are asserted across both.
+///
+///
+public class GeneratorErrorIdentityTests
+{
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void RuntimeTypeError_CaughtInGeneratorBody_InstanceofHolds(ExecutionMode mode)
+ {
+ // The exact #543 repro: a "not a function" TypeError caught inside the generator body.
+ var source = """
+ function* g() {
+ const o: any = undefined;
+ try { o.foo(); } catch (e: any) { console.log((e instanceof TypeError) + " " + e.name); }
+ yield 1;
+ }
+ g().next();
+ """;
+
+ Assert.Equal("true TypeError\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.CompiledOnly), MemberType = typeof(ExecutionModes))]
+ public void RuntimeTypeError_CaughtInGeneratorBody_AfterYield_InstanceofHolds(ExecutionMode mode)
+ {
+ // The flag-based shape: a yield before the throwing call forces the try into the flag-based
+ // scheme, whose catch binding must also produce a real TypeError.
+ var source = """
+ function* g() {
+ const o: any = undefined;
+ try { yield 0; o.foo(); } catch (e: any) { console.log((e instanceof TypeError) + " " + e.name); }
+ yield 1;
+ }
+ const it = g(); it.next(); it.next();
+ """;
+
+ Assert.Equal("true TypeError\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void ExplicitlyThrownError_CaughtInGeneratorBody_InstanceofHolds(ExecutionMode mode)
+ {
+ // Explicit `throw new RangeError(...)` caught in-body keeps its identity in both modes.
+ var source = """
+ function* g() {
+ try { yield 0; throw new RangeError("rr"); }
+ catch (e: any) { console.log((e instanceof RangeError) + " " + (e instanceof Error) + " " + e.name + " " + e.message); }
+ yield 1;
+ }
+ const it = g(); it.next(); it.next();
+ """;
+
+ Assert.Equal("true true RangeError rr\n", TestHarness.Run(source, mode));
+ }
+}
diff --git a/SharpTS.Tests/SharedTests/GeneratorTests.cs b/SharpTS.Tests/SharedTests/GeneratorTests.cs
index 3cbc8851..42566f94 100644
--- a/SharpTS.Tests/SharedTests/GeneratorTests.cs
+++ b/SharpTS.Tests/SharedTests/GeneratorTests.cs
@@ -1325,9 +1325,9 @@ function make() {
// * Compiled tests resolve the receiver through a live object property (`h.it`), because
// compiled generators capture closure variables by value (#541) — a self-assigned `it` is
// snapshotted as `undefined`, so the `let it; it = g()` form never reaches the guard.
- // * `instanceof TypeError` is asserted only when the error is caught OUTSIDE the generator; a
- // separate pre-existing bug (#543) makes `instanceof` report false for an error caught
- // inside a compiled generator body (its `.name`/`.message` are still correct).
+ // * `instanceof TypeError` now holds for an error caught inside a compiled generator body too
+ // (#543, fixed): the compiled re-entrancy test below asserts it, as does
+ // GeneratorErrorIdentityTests for the runtime "not a function" TypeError.
// The yield*-delegation case doesn't rely on the captured self-reference (the inner generator is
// created after the outer is assigned), so it runs in both modes from a single source.
@@ -1468,12 +1468,13 @@ public void GeneratorExpression_ReentrantNext_ThrowsTypeError(ExecutionMode mode
public void Generator_ReentrantNext_Compiled_ThrowsTypeErrorThenResumes(ExecutionMode mode)
{
// The re-entrant next() throws a catchable TypeError; once caught, the generator is still
- // suspended-able and resumes normally (the guard must not corrupt its running state).
+ // suspended-able and resumes normally (the guard must not corrupt its running state). The
+ // in-body catch also satisfies `instanceof TypeError` (#543, fixed).
var source = """
const h: any = {};
function* g() {
try { h.it.next(); }
- catch (e: any) { console.log(e.name, e.message); }
+ catch (e: any) { console.log(e instanceof TypeError, e.name, e.message); }
yield 1;
}
h.it = g();
@@ -1482,7 +1483,7 @@ public void Generator_ReentrantNext_Compiled_ThrowsTypeErrorThenResumes(Executio
""";
var output = TestHarness.Run(source, mode);
- Assert.Equal("TypeError Generator is already running\n1 false\n", output);
+ Assert.Equal("true TypeError Generator is already running\n1 false\n", output);
}
[Theory]
diff --git a/SharpTS.Tests/SharedTests/GeneratorTryFinallyTests.cs b/SharpTS.Tests/SharedTests/GeneratorTryFinallyTests.cs
index cb307379..7a625099 100644
--- a/SharpTS.Tests/SharedTests/GeneratorTryFinallyTests.cs
+++ b/SharpTS.Tests/SharedTests/GeneratorTryFinallyTests.cs
@@ -863,6 +863,130 @@ public void BareThrowOnSuspendedGenerator_InjectsUndefined(ExecutionMode mode)
Assert.Equal("c:undefined\n", TestHarness.Run(source, mode));
}
+ // ---- #632: a throw escaping a handler body must reach an enclosing flag-based try's catch ----
+ // EmitThrow routed a handler-body throw only through enclosing *finally* frames, then did a real IL
+ // throw — which bypasses an outer flag-based try's caughtException flag, so its catch never ran. The
+ // throw is now routed into the enclosing try's capture local (running the finally(s) inside that try
+ // first) and branched to its cleanup, the catch-side analog of the existing finally routing.
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void RethrowFromCatch_CaughtByEnclosingTryCatch(ExecutionMode mode)
+ {
+ // The exact #632 repro: an inner catch rethrows; the outer catch must catch the rethrown value.
+ var source = """
+ function* g() {
+ try {
+ try { yield 0; throw "inner"; } catch (e: any) { console.log("inner caught " + e); throw "rethrown"; }
+ } catch (e: any) { console.log("outer caught " + e); }
+ yield 1;
+ }
+ for (const v of g()) console.log("v" + v);
+ """;
+
+ Assert.Equal("v0\ninner caught inner\nouter caught rethrown\nv1\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void ThrowFromCatch_WithInterveningFinally_RunsFinallyThenOuterCatch(ExecutionMode mode)
+ {
+ // The inner try has a finally: a throw escaping the inner catch must run that finally before the
+ // outer catch sees the value (the finally is routed via FinallyFramesInside).
+ var source = """
+ function* g() {
+ try {
+ try { yield 0; throw "inner"; }
+ catch (e: any) { console.log("C1 " + e); throw "rethrown"; }
+ finally { console.log("F1"); }
+ } catch (e: any) { console.log("outer " + e); }
+ yield 1;
+ }
+ for (const v of g()) console.log("v" + v);
+ """;
+
+ Assert.Equal("v0\nC1 inner\nF1\nouter rethrown\nv1\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void UncaughtThrowFromCatchlessTryFinally_CaughtByEnclosingTry(ExecutionMode mode)
+ {
+ // An uncaught exception leaving a catch-less inner try/finally must, after its finally runs,
+ // propagate to the enclosing flag-based catch rather than escape the state machine (the
+ // try/finally analog: the post-finally rethrow now routes to the enclosing try too).
+ var source = """
+ function* g() {
+ try {
+ try { yield 0; throw "x"; } finally { console.log("F"); }
+ } catch (e: any) { console.log("outer " + e); }
+ yield 1;
+ }
+ for (const v of g()) console.log("v" + v);
+ """;
+
+ Assert.Equal("v0\nF\nouter x\nv1\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void ThrowFromFinallyBody_CaughtByEnclosingTry(ExecutionMode mode)
+ {
+ // A throw from a finally body propagates to the enclosing try's catch the same way.
+ var source = """
+ function* g() {
+ try {
+ try { yield 0; } finally { console.log("F1"); throw "fromFinally"; }
+ } catch (e: any) { console.log("outer " + e); }
+ yield 1;
+ }
+ for (const v of g()) console.log("v" + v);
+ """;
+
+ Assert.Equal("v0\nF1\nouter fromFinally\nv1\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void RethrowAcrossTwoNestedFinallys_RunsBothBeforeEnclosingCatch(ExecutionMode mode)
+ {
+ // Two finallys lie between the rethrow and the enclosing catch; both run, innermost first.
+ var source = """
+ function* g() {
+ try {
+ try {
+ try { yield 0; throw "deep"; } finally { console.log("Finner"); }
+ } catch (e: any) { console.log("Cmid " + e); throw "remid"; } finally { console.log("Fmid"); }
+ } catch (e: any) { console.log("outer " + e); }
+ yield 1;
+ }
+ for (const v of g()) console.log("v" + v);
+ """;
+
+ Assert.Equal("v0\nFinner\nCmid deep\nFmid\nouter remid\nv1\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void ExternalThrowAtCatchBodyYield_CaughtByEnclosingTry(ExecutionMode mode)
+ {
+ // An external it.throw() injected at a yield *inside a catch body* must also propagate to the
+ // enclosing try's catch (the injection-path analog of the lexical handler-body throw fix).
+ var source = """
+ function* g() {
+ try {
+ try { throw "a"; } catch (e: any) { yield 1; console.log("unreached"); }
+ } catch (e: any) { console.log("outer " + e); }
+ yield 99;
+ }
+ const it = g();
+ const r1 = it.next(); console.log(r1.value, r1.done);
+ const r2 = it.throw("b"); console.log(r2.value, r2.done);
+ """;
+
+ Assert.Equal("1 false\nouter b\n99 false\n", TestHarness.Run(source, mode));
+ }
+
// ---- IL-verification guards (the heart of #477: emitted IL must verify) ----
[Theory]
@@ -918,6 +1042,12 @@ public void BareThrowOnSuspendedGenerator_InjectsUndefined(ExecutionMode mode)
[InlineData("function* g() { try { yield 1; throw null; } catch (e) { console.log(e); } } for (const v of g()) {}")]
[InlineData("function* g() { try { yield 1; throw undefined; } catch (e) { console.log(e); } } for (const v of g()) {}")]
[InlineData("function* g() { try { yield 1; throw null; } finally { yield 2; } } try { for (const v of g()) {} } catch (e) {}")]
+ // #632: a throw/rethrow escaping a handler routed into an enclosing flag-based try's catch — the new
+ // routing (with intervening finallys, possibly yielding) must verify.
+ [InlineData("function* g() { try { try { yield 0; throw 'a'; } catch (e) { throw 'b'; } } catch (e) { console.log(e); } yield 1; } for (const v of g()) {}")]
+ [InlineData("function* g() { try { try { yield 0; throw 'a'; } catch (e) { throw 'b'; } finally { console.log('f'); } } catch (e) { console.log(e); } } for (const v of g()) {}")]
+ [InlineData("function* g() { try { try { yield 0; throw 'x'; } finally { yield 7; } } catch (e) { console.log(e); } } for (const v of g()) {}")]
+ [InlineData("function* g() { try { try { yield 0; } finally { throw 'fin'; } } catch (e) { console.log(e); } } for (const v of g()) {}")]
public void GeneratorTryFinallyWithYield_EmitsVerifiableIL(string source)
{
var errors = TestHarness.CompileAndVerifyOnly(source);