From 88d5f74cbc3217f94d559580f5054dcc45637e79 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Tue, 16 Jun 2026 00:41:47 -0700 Subject: [PATCH 1/5] Fix #727: async-function break/continue out of a try emits invalid IL AsyncMoveNextEmitter lacked the protected-region-aware branching the generator emitters have, so a break/continue leaving a try inside an async function body emitted a Br out of the real IL exception region (BranchOutOfTry -> InvalidProgramException at AsyncMethodBuilderCore.Start). - Override EmitBranchToLabel to emit Leave when inside a real IL exception block (ExceptionBlockDepth), incremented in EmitSimpleTryCatch. - Treat an escaping break/continue/return as a segment-breaker in EmitTryBodyWithAwaits so its jump lands at the top level (outside the mini IL try), mirroring the generator's IsSegmentBreaker design. - Hoist the duplicated ContainsEscapingExit/ContainsEscapingExit2 analysis (verbatim in the generator and async-generator emitters) into the shared StatementEmitterBase base so all three suspension-aware emitters use one copy. Adds AsyncTryControlFlowTests (cross-mode parity + IL-validity facts). --- ...atorMoveNextEmitter.Statements.TryCatch.cs | 68 +----- ...cMoveNextEmitter.Statements.ControlFlow.cs | 17 ++ ...syncMoveNextEmitter.Statements.TryCatch.cs | 73 ++++--- ...atorMoveNextEmitter.Statements.TryCatch.cs | 68 +----- Compilation/StatementEmitterBase.cs | 72 +++++++ .../SharedTests/AsyncTryControlFlowTests.cs | 197 ++++++++++++++++++ 6 files changed, 332 insertions(+), 163 deletions(-) create mode 100644 SharpTS.Tests/SharedTests/AsyncTryControlFlowTests.cs diff --git a/Compilation/AsyncGeneratorMoveNextEmitter.Statements.TryCatch.cs b/Compilation/AsyncGeneratorMoveNextEmitter.Statements.TryCatch.cs index 424551c1..28dfbe3a 100644 --- a/Compilation/AsyncGeneratorMoveNextEmitter.Statements.TryCatch.cs +++ b/Compilation/AsyncGeneratorMoveNextEmitter.Statements.TryCatch.cs @@ -859,71 +859,9 @@ private static bool ContainsSuspensionInExpr(Expr expr) } } - /// - /// Detects return/break/continue that would transfer control out of the surrounding try - /// region. Over-approximates conservatively (labeled break/continue are always treated as - /// escaping): a false positive only costs a statement some mini-segment exception coverage, - /// whereas a false negative would emit a `br`/`ret` inside a protected region (illegal IL). - /// Nested function/arrow bodies are not traversed (their returns are their own). - /// - private static bool ContainsEscapingExit(Stmt stmt, bool insideLoop, bool insideSwitch) - { - switch (stmt) - { - case Stmt.Return: - return true; - case Stmt.Break b: - return b.Label != null || !(insideLoop || insideSwitch); - case Stmt.Continue c: - return c.Label != null || !insideLoop; - case Stmt.If i: - return ContainsEscapingExit(i.ThenBranch, insideLoop, insideSwitch) - || (i.ElseBranch != null && ContainsEscapingExit(i.ElseBranch, insideLoop, insideSwitch)); - case Stmt.Block b: - if (b.Statements == null) return false; - foreach (var s in b.Statements) - if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; - return false; - case Stmt.Sequence seq: - foreach (var s in seq.Statements) - if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; - return false; - case Stmt.While w: - return ContainsEscapingExit(w.Body, insideLoop: true, insideSwitch); - case Stmt.DoWhile dw: - return ContainsEscapingExit(dw.Body, insideLoop: true, insideSwitch); - case Stmt.For f: - return ContainsEscapingExit(f.Body, insideLoop: true, insideSwitch); - case Stmt.ForOf fo: - return ContainsEscapingExit(fo.Body, insideLoop: true, insideSwitch); - case Stmt.ForIn fi: - return ContainsEscapingExit(fi.Body, insideLoop: true, insideSwitch); - case Stmt.Switch s: - foreach (var c in s.Cases) - foreach (var cs in c.Body) - if (ContainsEscapingExit(cs, insideLoop, insideSwitch: true)) return true; - if (s.DefaultBody != null) - foreach (var ds in s.DefaultBody) - if (ContainsEscapingExit(ds, insideLoop, insideSwitch: true)) return true; - return false; - case Stmt.LabeledStatement ls: - return ContainsEscapingExit(ls.Statement, insideLoop, insideSwitch); - case Stmt.TryCatch t: - if (ContainsEscapingExit2(t.TryBlock, insideLoop, insideSwitch)) return true; - if (t.CatchBlock != null && ContainsEscapingExit2(t.CatchBlock, insideLoop, insideSwitch)) return true; - if (t.FinallyBlock != null && ContainsEscapingExit2(t.FinallyBlock, insideLoop, insideSwitch)) return true; - return false; - default: - return false; - } - } - - private static bool ContainsEscapingExit2(List statements, bool insideLoop, bool insideSwitch) - { - foreach (var s in statements) - if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; - return false; - } + // ContainsEscapingExit / ContainsEscapingExit2 are shared across the suspension-aware emitters and + // live in StatementEmitterBase (the generator, async-generator, and async-function emitters all + // segment a flag-based try body around non-local exits using the same conservative analysis). #endregion } diff --git a/Compilation/AsyncMoveNextEmitter.Statements.ControlFlow.cs b/Compilation/AsyncMoveNextEmitter.Statements.ControlFlow.cs index fbe2affa..8646bba5 100644 --- a/Compilation/AsyncMoveNextEmitter.Statements.ControlFlow.cs +++ b/Compilation/AsyncMoveNextEmitter.Statements.ControlFlow.cs @@ -39,5 +39,22 @@ protected override void EmitReturn(Stmt.Return r) _il.Emit(OpCodes.Leave, _setResultLabel); } + /// + /// Branch out to (a loop's break/continue label). Inside a real IL + /// exception block a br out is illegal IL (BranchOutOfTry), so use Leave — which exits + /// the block legally and runs its (no-await) finally. ExceptionBlockDepth counts only real + /// blocks opened by , not the flag-based path's mini try/catch + /// segments (EmitSegmentInTry), so an escaping break/continue in a try-with-awaits is pulled out as a + /// segment-breaker (emitted at the top level, depth 0 → Br) while a break targeting a loop + /// nested inside a segment stays a legal in-segment Br. Mirrors the generator emitters (#727). + /// + protected override void EmitBranchToLabel(Label target) + { + if (_ctx!.ExceptionBlockDepth > 0) + _il.Emit(OpCodes.Leave, target); + else + _il.Emit(OpCodes.Br, target); + } + // EmitIf: inherited from StatementEmitterBase (identical logic) } diff --git a/Compilation/AsyncMoveNextEmitter.Statements.TryCatch.cs b/Compilation/AsyncMoveNextEmitter.Statements.TryCatch.cs index 4cab499b..db49796f 100644 --- a/Compilation/AsyncMoveNextEmitter.Statements.TryCatch.cs +++ b/Compilation/AsyncMoveNextEmitter.Statements.TryCatch.cs @@ -27,6 +27,13 @@ protected override void EmitTryCatch(Stmt.TryCatch t) private void EmitSimpleTryCatch(Stmt.TryCatch t) { + // A real IL protected region is open across the whole try/catch/finally. A `br`/`ret` directly + // out of it is illegal, so a non-local break/continue crossing it must use `Leave` instead — + // which also runs this (no-await) finally. ExceptionBlockDepth drives the Leave-vs-Br choice in + // EmitBranchToLabel; it is incremented only here (not in the flag path's mini try/catch + // segments), so a break targeting a loop nested inside the try stays a legal in-region `Br` + // while an escaping break/continue leaves via `Leave` (#727). + _ctx!.ExceptionBlockDepth++; _il.BeginExceptionBlock(); // Emit try block statements @@ -68,6 +75,7 @@ private void EmitSimpleTryCatch(Stmt.TryCatch t) } _il.EndExceptionBlock(); + _ctx!.ExceptionBlockDepth--; } private void EmitTryCatchWithAwaits(Stmt.TryCatch t, bool hasAwaitsInTry, bool hasAwaitsInCatch, bool hasAwaitsInFinally) @@ -276,53 +284,52 @@ private void EmitTryBodyWithAwaits(List tryBody, LocalBuilder caughtExcept _currentTryCatchExceptionLocal = caughtExceptionLocal; _currentTryCatchSkipLabel = afterTryLabel; - List stmtsBeforeAwait = []; + List syncSegment = []; + + void FlushSegment() + { + if (syncSegment.Count == 0) + return; + // Skip the segment if an earlier one already threw (its exception heads to the catch). + var skipSegmentLabel = _il.DefineLabel(); + _il.Emit(OpCodes.Ldloc, caughtExceptionLocal); + _il.Emit(OpCodes.Brtrue, skipSegmentLabel); + EmitSegmentInTry(syncSegment, caughtExceptionLocal); + _il.MarkLabel(skipSegmentLabel); + syncSegment.Clear(); + } foreach (var stmt in tryBody) { - if (ContainsAwait([stmt])) + // A "segment breaker" must be emitted at the top level rather than inside a mini IL + // try/catch: a suspension point (await) whose resume label can't be branched into a + // protected region, or a non-local exit (break/continue/return) whose `Br`/`Leave` out of + // the try targets the enclosing loop — both illegal inside the segment's real IL block. An + // escaping break/continue branches with `Br` at this top level (ExceptionBlockDepth is 0), + // which is what makes it legal; previously it landed inside a segment and `Br`'d out of the + // mini try (BranchOutOfTry → invalid IL) (#727). + if (ContainsAwait([stmt]) || ContainsEscapingExit(stmt, insideLoop: false, insideSwitch: false)) { - // Emit accumulated statements in a try block - if (stmtsBeforeAwait.Count > 0) - { - // Check if exception was already caught - var skipSegmentLabel = _il.DefineLabel(); - _il.Emit(OpCodes.Ldloc, caughtExceptionLocal); - _il.Emit(OpCodes.Brtrue, skipSegmentLabel); - - EmitSegmentInTry(stmtsBeforeAwait, caughtExceptionLocal); - _il.MarkLabel(skipSegmentLabel); - stmtsBeforeAwait.Clear(); - } - - // Check if exception was caught before continuing with await - var skipAwaitLabel = _il.DefineLabel(); + FlushSegment(); + + // If an earlier segment threw, skip this suspension/exit and fall through to the catch. + var skipLabel = _il.DefineLabel(); _il.Emit(OpCodes.Ldloc, caughtExceptionLocal); - _il.Emit(OpCodes.Brtrue, skipAwaitLabel); + _il.Emit(OpCodes.Brtrue, skipLabel); - // Emit the statement containing await - // EmitAwait will check _currentTryCatchExceptionLocal and wrap GetResult in try/catch + // EmitAwait checks _currentTryCatchExceptionLocal and wraps GetResult in try/catch; a + // break/continue/return emits its jump here, outside any real IL exception block. EmitStatement(stmt); - _il.MarkLabel(skipAwaitLabel); + _il.MarkLabel(skipLabel); } else { - stmtsBeforeAwait.Add(stmt); + syncSegment.Add(stmt); } } - // Emit remaining statements in a try block - if (stmtsBeforeAwait.Count > 0) - { - // Check if exception was already caught - var skipLabel = _il.DefineLabel(); - _il.Emit(OpCodes.Ldloc, caughtExceptionLocal); - _il.Emit(OpCodes.Brtrue, skipLabel); - - EmitSegmentInTry(stmtsBeforeAwait, caughtExceptionLocal); - _il.MarkLabel(skipLabel); - } + FlushSegment(); _il.MarkLabel(afterTryLabel); diff --git a/Compilation/GeneratorMoveNextEmitter.Statements.TryCatch.cs b/Compilation/GeneratorMoveNextEmitter.Statements.TryCatch.cs index 712ced5d..bbcb879b 100644 --- a/Compilation/GeneratorMoveNextEmitter.Statements.TryCatch.cs +++ b/Compilation/GeneratorMoveNextEmitter.Statements.TryCatch.cs @@ -1029,71 +1029,9 @@ private static bool ContainsYieldInExpr(Expr expr) } } - /// - /// Detects return/break/continue that would transfer control out of the surrounding try - /// region. Over-approximates conservatively (labeled break/continue are always treated as - /// escaping): a false positive only costs a statement some mini-segment exception coverage, - /// whereas a false negative would emit a `br`/`ret` inside a protected region (illegal IL). - /// Nested function/arrow bodies are not traversed (their returns are their own). - /// - private static bool ContainsEscapingExit(Stmt stmt, bool insideLoop, bool insideSwitch) - { - switch (stmt) - { - case Stmt.Return: - return true; - case Stmt.Break b: - return b.Label != null || !(insideLoop || insideSwitch); - case Stmt.Continue c: - return c.Label != null || !insideLoop; - case Stmt.If i: - return ContainsEscapingExit(i.ThenBranch, insideLoop, insideSwitch) - || (i.ElseBranch != null && ContainsEscapingExit(i.ElseBranch, insideLoop, insideSwitch)); - case Stmt.Block b: - if (b.Statements == null) return false; - foreach (var s in b.Statements) - if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; - return false; - case Stmt.Sequence seq: - foreach (var s in seq.Statements) - if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; - return false; - case Stmt.While w: - return ContainsEscapingExit(w.Body, insideLoop: true, insideSwitch); - case Stmt.DoWhile dw: - return ContainsEscapingExit(dw.Body, insideLoop: true, insideSwitch); - case Stmt.For f: - return ContainsEscapingExit(f.Body, insideLoop: true, insideSwitch); - case Stmt.ForOf fo: - return ContainsEscapingExit(fo.Body, insideLoop: true, insideSwitch); - case Stmt.ForIn fi: - return ContainsEscapingExit(fi.Body, insideLoop: true, insideSwitch); - case Stmt.Switch s: - foreach (var c in s.Cases) - foreach (var cs in c.Body) - if (ContainsEscapingExit(cs, insideLoop, insideSwitch: true)) return true; - if (s.DefaultBody != null) - foreach (var ds in s.DefaultBody) - if (ContainsEscapingExit(ds, insideLoop, insideSwitch: true)) return true; - return false; - case Stmt.LabeledStatement ls: - return ContainsEscapingExit(ls.Statement, insideLoop, insideSwitch); - case Stmt.TryCatch t: - if (ContainsEscapingExit2(t.TryBlock, insideLoop, insideSwitch)) return true; - if (t.CatchBlock != null && ContainsEscapingExit2(t.CatchBlock, insideLoop, insideSwitch)) return true; - if (t.FinallyBlock != null && ContainsEscapingExit2(t.FinallyBlock, insideLoop, insideSwitch)) return true; - return false; - default: - return false; - } - } - - private static bool ContainsEscapingExit2(List statements, bool insideLoop, bool insideSwitch) - { - foreach (var s in statements) - if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; - return false; - } + // ContainsEscapingExit / ContainsEscapingExit2 are shared across the suspension-aware emitters and + // live in StatementEmitterBase (the generator, async-generator, and async-function emitters all + // segment a flag-based try body around non-local exits using the same conservative analysis). #endregion } diff --git a/Compilation/StatementEmitterBase.cs b/Compilation/StatementEmitterBase.cs index 84efca8d..5e8745f5 100644 --- a/Compilation/StatementEmitterBase.cs +++ b/Compilation/StatementEmitterBase.cs @@ -1000,6 +1000,78 @@ protected static Stmt UnwrapLabelChain(Stmt.LabeledStatement ls, out List stmt is Stmt.While or Stmt.DoWhile or Stmt.For or Stmt.ForOf or Stmt.ForIn; + /// + /// Detects return/break/continue that would transfer control out of the surrounding try + /// region. Over-approximates conservatively (labeled break/continue are always treated as + /// escaping): a false positive only costs a statement some mini-segment exception coverage, + /// whereas a false negative would emit a br/ret inside a protected region (illegal IL). + /// Nested function/arrow bodies are not traversed (their returns are their own). + /// + /// + /// Shared by the suspension-aware emitters (generator, async generator, async function): each + /// segments a flag-based try body so a suspension point or a non-local exit lands at the top level + /// (outside the mini IL try/catch), where its ret/br/Leave is legal. + /// + protected static bool ContainsEscapingExit(Stmt stmt, bool insideLoop, bool insideSwitch) + { + switch (stmt) + { + case Stmt.Return: + return true; + case Stmt.Break b: + return b.Label != null || !(insideLoop || insideSwitch); + case Stmt.Continue c: + return c.Label != null || !insideLoop; + case Stmt.If i: + return ContainsEscapingExit(i.ThenBranch, insideLoop, insideSwitch) + || (i.ElseBranch != null && ContainsEscapingExit(i.ElseBranch, insideLoop, insideSwitch)); + case Stmt.Block b: + if (b.Statements == null) return false; + foreach (var s in b.Statements) + if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; + return false; + case Stmt.Sequence seq: + foreach (var s in seq.Statements) + if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; + return false; + case Stmt.While w: + return ContainsEscapingExit(w.Body, insideLoop: true, insideSwitch); + case Stmt.DoWhile dw: + return ContainsEscapingExit(dw.Body, insideLoop: true, insideSwitch); + case Stmt.For f: + return ContainsEscapingExit(f.Body, insideLoop: true, insideSwitch); + case Stmt.ForOf fo: + return ContainsEscapingExit(fo.Body, insideLoop: true, insideSwitch); + case Stmt.ForIn fi: + return ContainsEscapingExit(fi.Body, insideLoop: true, insideSwitch); + case Stmt.Switch s: + foreach (var c in s.Cases) + foreach (var cs in c.Body) + if (ContainsEscapingExit(cs, insideLoop, insideSwitch: true)) return true; + if (s.DefaultBody != null) + foreach (var ds in s.DefaultBody) + if (ContainsEscapingExit(ds, insideLoop, insideSwitch: true)) return true; + return false; + case Stmt.LabeledStatement ls: + return ContainsEscapingExit(ls.Statement, insideLoop, insideSwitch); + case Stmt.TryCatch t: + if (ContainsEscapingExit2(t.TryBlock, insideLoop, insideSwitch)) return true; + if (t.CatchBlock != null && ContainsEscapingExit2(t.CatchBlock, insideLoop, insideSwitch)) return true; + if (t.FinallyBlock != null && ContainsEscapingExit2(t.FinallyBlock, insideLoop, insideSwitch)) return true; + return false; + default: + return false; + } + } + + /// Any statement in contains an escaping exit (see ). + protected static bool ContainsEscapingExit2(List statements, bool insideLoop, bool insideSwitch) + { + foreach (var s in statements) + if (ContainsEscapingExit(s, insideLoop, insideSwitch)) return true; + return false; + } + /// /// Emits a switch statement. /// diff --git a/SharpTS.Tests/SharedTests/AsyncTryControlFlowTests.cs b/SharpTS.Tests/SharedTests/AsyncTryControlFlowTests.cs new file mode 100644 index 00000000..e4d4bf87 --- /dev/null +++ b/SharpTS.Tests/SharedTests/AsyncTryControlFlowTests.cs @@ -0,0 +1,197 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.SharedTests; + +/// +/// Regression tests for #727: in compiled mode, a break/continue that leaves a +/// try inside an async function body previously emitted a Br out of the real IL +/// exception region instead of a Leave — unverifiable IL (BranchOutOfTry) surfacing as an +/// InvalidProgramException when the async state machine starts. The fix mirrors the generator +/// emitters: now overrides +/// EmitBranchToLabel to emit Leave while inside a real IL exception block (depth tracked +/// in EmitSimpleTryCatch), and treats an escaping break/continue as a +/// segment-breaker in EmitTryBodyWithAwaits so its jump lands at the top level (outside the +/// mini IL try). The interpreter already behaved correctly, so the cross-mode theories double as a +/// parity guard. The CompileVerifyAndRun facts pin IL validity (the runtime JIT is lenient). +/// +public class AsyncTryControlFlowTests +{ + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void BreakOutOfSimpleTry_InAsync(ExecutionMode mode) + { + // The exact #727 break repro. + var source = """ + async function main() { + let n = 0; + for (let i = 0; i < 5; i++) { + try { if (i === 2) break; n++; } catch (e) {} + } + console.log("n=" + n); + } + main(); + """; + + Assert.Equal("n=2\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ContinueOutOfSimpleTry_InAsync(ExecutionMode mode) + { + // The exact #727 continue repro. + var source = """ + async function main() { + let sum = 0; + for (let i = 0; i < 5; i++) { + try { if (i === 1) continue; sum += i; } catch (e) {} + } + console.log("sum=" + sum); + } + main(); + """; + + Assert.Equal("sum=9\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void BreakOutOfTryContainingAwait_InAsync(ExecutionMode mode) + { + // break leaves a try whose body awaits → the flag-based try path. The escaping break must be + // emitted at the top level (outside the mini IL try), not Br out of it. + var source = """ + function delay(v: number): Promise { return new Promise(r => r(v)); } + async function main() { + let n = 0; + for (let i = 0; i < 5; i++) { + try { + const v = await delay(i); + if (v === 2) break; + n += v; + } catch (e) {} + } + console.log("n=" + n); + } + main(); + """; + + Assert.Equal("n=1\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void BreakOutOfTryWithFinally_NoAwait_RunsFinally(ExecutionMode mode) + { + // A try with no awaits is a real IL try/finally (EmitSimpleTryCatch). The break Leaves it, + // which runs the real finally — so cleanup happens even on the early exit. + var source = """ + async function main() { + let log = ""; + for (let i = 0; i < 4; i++) { + try { if (i === 2) break; log += "t" + i; } finally { log += "f" + i; } + } + console.log(log); + } + main(); + """; + + Assert.Equal("t0f0t1f1f2\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void BreakToInnerLoopInsideTry_DoesNotEscapeTry(ExecutionMode mode) + { + // The break targets a loop nested INSIDE the try, so it stays a legal in-region branch and the + // statements after the inner loop (still inside the try) run. + var source = """ + async function main() { + let log = ""; + try { + for (let j = 0; j < 3; j++) { if (j === 1) break; log += "j" + j; } + log += "after"; + } catch (e) {} + console.log(log); + } + main(); + """; + + Assert.Equal("j0after\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void SwitchBreakInsideTry_InAsync(ExecutionMode mode) + { + // An unlabeled break inside a switch that is inside a try belongs to the switch, not the loop. + var source = """ + async function main() { + let log = ""; + for (let i = 0; i < 3; i++) { + try { + switch (i) { case 1: log += "one"; break; default: log += "d" + i; } + log += "|"; + } catch (e) {} + } + console.log(log); + } + main(); + """; + + Assert.Equal("d0|one|d2|\n", TestHarness.Run(source, mode)); + } + + [Fact] + public void LabeledBreakOutOfTryWithAwait_AcrossNestedLoops_Compiled() + { + // Labeled `break outer` leaves a try whose body awaits, across nested loops. Compiled mode is + // correct; the interpreter mishandles this shape (tracked separately), so this is compiled-only. + var source = """ + async function main() { + let log = ""; + outer: for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + try { + const x = await Promise.resolve(i * 10 + j); + if (x === 11) break outer; + log += x + ","; + } catch (e) {} + } + } + console.log(log); + } + main(); + """; + + var (errors, output) = TestHarness.CompileVerifyAndRun(source); + Assert.Empty(errors); + Assert.Equal("0,1,2,10,\n", output); + } + + [Fact] + public void BreakContinueOutOfTry_ProduceValidIL() + { + // Pin IL validity directly (the runtime JIT is lenient and would run invalid IL anyway). + var source = """ + async function main() { + let n = 0; + for (let i = 0; i < 5; i++) { + try { if (i === 2) break; n++; } catch (e) {} + } + for (let i = 0; i < 5; i++) { + try { if (i === 1) continue; n += i; } catch (e) {} + } + for (let i = 0; i < 5; i++) { + try { const v = await Promise.resolve(i); if (v === 2) break; n += v; } catch (e) {} + } + console.log("n=" + n); + } + main(); + """; + + var (errors, _) = TestHarness.CompileVerifyAndRun(source); + Assert.Empty(errors); + } +} From ef3a9b837352b5ca97fc6944abcb4bb33194bdd1 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Tue, 16 Jun 2026 00:52:44 -0700 Subject: [PATCH 2/5] Fix #728: labeled for-await iterated synchronously (interpreter + compiled) A 'for await...of' carrying a label (outer: for await (...)) was iterated synchronously: the Stmt.ForOf.IsAsync flag never reached the async-iteration lowering on the labeled path. It threw 'for...of requires an iterable' (interpreter) or failed to compile ('Label N has not been marked', from the unconsumed reserved await states) while an unlabeled for-await worked. Interpreter: register an async LabeledStatement handler (ExecuteLabeledStatementAsyncVT) so a labeled loop in async code runs through the async path (where for-await routes to the async iterator), and have the async loops drain TakePendingLoopLabels() so break/continue