From a58a38187a8eb31c9541682e6cdfa63887520c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 4 Sep 2026 15:13:28 +0200 Subject: [PATCH] fix(transform): unwind a labeled escape out of nested loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `break label` / `continue label` that targets an outer loop from inside a nested loop threw `TypeError: Cannot read properties of undefined (reading 'done')` (break) or silently produced nothing (continue), in sync generators, async generators and async functions alike. Generator linearization gives each loop one break sentinel and one continue sentinel, so a completion can only name the loop it sits in. `rewrite_labeled_bc_in_stmts` converts labeled completions to plain ones at the labeled loop's own body level and stops at nested loops — a plain completion there would bind to the nested loop. The escape that was left survived into a state body, where the dispatch lowering has no sentinel for it and dropped it. The code noted the gap ("the single-sentinel scheme can't yet distinguish targets"). Unwind it through a carrier local instead, so every completion the linearizer sees is plain: the escape sets the carrier and plain-breaks out of its loop, each intermediate loop propagates with `if (carrier != 0) break`, and the labeled loop turns the carrier back into the real `break`/`continue`. A switch carrying an escape is desugared to `if`s first, since a plain `break` inside a switch binds to the switch. The issue's own repro was already fixed by #9189; the surviving hole is the cross-loop target, where the switch turns out to be incidental. Closes #9199 --- .../9730-labeled-escape-nested-loops.md | 51 ++++ .../src/generator/break_continue.rs | 285 ++++++++++++++++++ .../src/generator/linearize.rs | 25 +- ...st_gap_9199_labeled_escape_nested_loops.ts | 106 +++++++ 4 files changed, 463 insertions(+), 4 deletions(-) create mode 100644 changelog.d/9730-labeled-escape-nested-loops.md create mode 100644 test-files/test_gap_9199_labeled_escape_nested_loops.ts diff --git a/changelog.d/9730-labeled-escape-nested-loops.md b/changelog.d/9730-labeled-escape-nested-loops.md new file mode 100644 index 0000000000..80f244a6e2 --- /dev/null +++ b/changelog.d/9730-labeled-escape-nested-loops.md @@ -0,0 +1,51 @@ +**A labeled `break`/`continue` that targets an outer loop from inside a nested +loop now works in generators, async generators and async functions** (#9199). +It previously threw `TypeError: Cannot read properties of undefined (reading +'done')` for `break`, and silently produced nothing for `continue`. + +```ts +async function* g() { + O: for (const x of [1, 2]) { I: for (const y of [0, 1]) { yield "b" + x + y; break O; } } +} +// node: b10 before: TypeError … reading 'done' +``` + +Generator linearization gives each loop a single `break` sentinel and a single +`continue` sentinel, so a completion can only name the loop it sits in. +`rewrite_labeled_bc_in_stmts` therefore converted `break label` / `continue +label` to plain completions **only at the labeled loop's own body level** and +stopped at nested loops — correctly, since a plain completion inside a nested +loop would bind to that loop. What was missing is what happens to the escape +that is left: it survived verbatim into a state body, where the dispatch +lowering has no sentinel for it and dropped it. The limitation was noted in the +code ("the single-sentinel scheme can't yet distinguish targets"). + +Rather than teach the state machine to name a distant target, the escape is now +unwound one loop at a time through a carrier local, so every completion the +linearizer sees is plain and binds to the loop it is in: + +``` +__esc = 0; +inner: while (…) { … __esc = 1; break; … } // was `break label` +if (__esc == 1) break; // in the labeled loop +if (__esc == 2) continue; +``` + +Deeper nesting reuses the same carrier and propagates outward with a bare +`if (__esc != 0) break;` after each intermediate loop. A `switch` that carries +an escape is desugared to `if`s first, since a plain `break` inside a switch +would bind to the switch. + +The hole was wider than the issue's own repro, which #9189 had already closed: +it reached sync generators and async functions as well as async generators, +and the `switch` in the report was incidental — a bare `break outer` in a +nested loop failed on its own, while the switch-wrapped form worked because +#9186's routing already handled it. + +`test-files/test_gap_9199_labeled_escape_nested_loops.ts` pins 13 shapes: +`break`/`continue` of an outer label from a nested loop in all three function +kinds, three-deep nesting, a `while` outer, an `await` before the escape, a +conditional escape, `try`/`finally` around it (finalizers still run in order), +a reused label name on a sibling loop, and the switch-wrapped form that already +worked. Unpatched the fixture throws on its first row and then hangs; patched +it is byte-identical to node 26.5.1. diff --git a/crates/perry-transform/src/generator/break_continue.rs b/crates/perry-transform/src/generator/break_continue.rs index ff3963d58d..0536466a39 100644 --- a/crates/perry-transform/src/generator/break_continue.rs +++ b/crates/perry-transform/src/generator/break_continue.rs @@ -817,3 +817,288 @@ pub fn stmts_have_continue_inside_try_finally(stmts: &[Stmt]) -> bool { _ => false, }) } + +// ── #9199: labeled break/continue that escapes a NESTED loop ────────────── +// +// `rewrite_labeled_bc_in_stmts` converts `break label` / `continue label` to +// plain completions only at the labeled loop's OWN body level, and stops at +// nested loops (a plain completion there would bind to the nested loop). The +// linearizer's single break/continue sentinel per loop then has no way to name +// an outer loop's target, so a labeled completion crossing a loop boundary +// survived verbatim into a state body and the dispatch lowering dropped it: +// `break` produced a malformed iterator result ("Cannot read properties of +// undefined (reading 'done')"), `continue` silently produced nothing at all. +// +// The fix is to stop asking the state machine to name a distant target. A +// carrier local unwinds the escape one loop at a time, so every completion the +// linearizer sees is plain and binds to the loop it sits in: +// +// __esc = 0; +// inner: while (…) { … __esc = 1; break; … } // `break label` +// if (__esc == 1) break; // in the labeled loop +// if (__esc == 2) continue; +// +// Deeper nesting reuses the same carrier and propagates with a bare +// `if (__esc != 0) break;` after each intermediate loop, so an escape from any +// depth walks out to the labeled loop without the linearizer ever seeing a +// labeled completion. + +/// Carrier value for a `break