Skip to content

Commit ca0e895

Browse files
proggeramlugRalph Küpper
andauthored
fix(transform): #5975 — labeled continue in a yielding switch nested in a labeled loop spins forever (#6060)
A `continue <label>` inside a yielding `switch` case nested in a labeled loop was never rewritten to a plain `continue`, so it survived verbatim into the #5868 desugared switch's state machine, where nothing lowers it: the loop never re-entered its dispatch state and re-ran the same `switch` forever (generator/async spun at 100% CPU, produced no value). The labeled-loop linearizer's `rewrite_labeled_bc_in_stmts` rewrites `continue <label>` -> plain `continue` (so the loop lowering maps it to the re-entry sentinel) but descended only `if`/`try` and stopped at `switch`; meanwhile #5868's `switch_cases_have_loop_continue` only detects a plain `Stmt::Continue`, so a `LabeledContinue` in a case was invisible to both. The unlabeled `continue;` always worked. Fix: descend into nested `switch` case bodies to rewrite `continue <label>` -> plain `continue` (a switch never captures `continue`, so it always continues the enclosing loop) — the exact path the unlabeled form already took. `break <label>` inside a nested switch is deliberately left alone (a switch captures `break`; that remains the pre-existing single-sentinel limitation already documented on the function). Repro shape: `loop: while (true) { switch (x) { case k: yield v; continue loop } break loop }` — hand-written generator lexers use it pervasively (e.g. the `yaml` package's indicator/block-scalar lexers), so native `yaml.parse()` of non-trivial input, and a large esbuild-bundled CLI app parsing YAML front-matter during module initialization, spun forever. New e2e suite crates/perry/tests/issue_5975_labeled_continue_in_yielding_switch.rs (generator while/for, async, and a yield*-delegating lexer analogue), each byte-for-byte vs node and timeout-bounded so a regression fails instead of hanging. cargo test -p perry-transform: 48 passed. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent ad8507d commit ca0e895

2 files changed

Lines changed: 276 additions & 0 deletions

File tree

crates/perry-transform/src/generator/linearize.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,67 @@ fn rewrite_labeled_bc_in_stmts(stmts: &mut [Stmt], label: &str) {
15981598
rewrite_labeled_bc_in_stmts(f, label);
15991599
}
16001600
}
1601+
// #5975: a `continue <label>` that targets THIS enclosing labeled
1602+
// loop from inside a nested `switch` case. A switch never captures
1603+
// `continue`, so it continues the loop — rewrite it to a plain
1604+
// `continue` here so the loop's linearization (and the #5868
1605+
// yielding-switch desugar) map it to the loop's re-entry sentinel.
1606+
// Without this the `LabeledContinue` survives verbatim into the
1607+
// desugared switch's state machine, where nothing lowers it, and a
1608+
// `loop: while (…) { switch (…) { case …: yield …; continue loop } }`
1609+
// (e.g. the `yaml` package's block-scalar / indicator lexer, a
1610+
// generator) spins forever. `break <label>` is deliberately NOT
1611+
// rewritten in a nested switch: a switch DOES capture `break`, so a
1612+
// plain `break` would exit only the switch, not the loop — that is
1613+
// the pre-existing single-sentinel limitation documented above.
1614+
Stmt::Switch { cases, .. } => {
1615+
for case in cases.iter_mut() {
1616+
rewrite_labeled_continue_in_stmts(&mut case.body, label);
1617+
}
1618+
}
1619+
_ => {}
1620+
}
1621+
}
1622+
}
1623+
1624+
/// Rewrite `continue <label>` → plain `continue` for `label`, descending
1625+
/// through `if` / `try` / `switch` (none of which capture `continue`) but
1626+
/// stopping at nested loops (which bind their own `continue`). Unlike
1627+
/// [`rewrite_labeled_bc_in_stmts`] this does NOT touch `break <label>`: it is
1628+
/// used only when recursing into a nested `switch`, where a plain `break`
1629+
/// would be captured by the switch rather than escaping to the loop (#5975).
1630+
fn rewrite_labeled_continue_in_stmts(stmts: &mut [Stmt], label: &str) {
1631+
for s in stmts.iter_mut() {
1632+
match s {
1633+
Stmt::LabeledContinue(l) if l == label => *s = Stmt::Continue,
1634+
Stmt::If {
1635+
then_branch,
1636+
else_branch,
1637+
..
1638+
} => {
1639+
rewrite_labeled_continue_in_stmts(then_branch, label);
1640+
if let Some(eb) = else_branch.as_mut() {
1641+
rewrite_labeled_continue_in_stmts(eb, label);
1642+
}
1643+
}
1644+
Stmt::Try {
1645+
body,
1646+
catch,
1647+
finally,
1648+
} => {
1649+
rewrite_labeled_continue_in_stmts(body, label);
1650+
if let Some(c) = catch.as_mut() {
1651+
rewrite_labeled_continue_in_stmts(&mut c.body, label);
1652+
}
1653+
if let Some(f) = finally.as_mut() {
1654+
rewrite_labeled_continue_in_stmts(f, label);
1655+
}
1656+
}
1657+
Stmt::Switch { cases, .. } => {
1658+
for case in cases.iter_mut() {
1659+
rewrite_labeled_continue_in_stmts(&mut case.body, label);
1660+
}
1661+
}
16011662
_ => {}
16021663
}
16031664
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
//! Regression test for #5975: a `continue <label>` inside a **yielding**
2+
//! `switch` case nested in a labeled loop spun forever.
3+
//!
4+
//! `loop: while (true) { switch (x) { case …: yield …; continue loop } break loop }`
5+
//! desugars the switch (#5868) into a guarded `if`-chain and splits it at the
6+
//! `yield`, but the labeled-loop linearizer's `rewrite_labeled_bc_in_stmts`
7+
//! never descended into the nested switch, so the `continue loop`
8+
//! (`LabeledContinue`) survived verbatim into the state machine. Nothing
9+
//! lowered it, the loop never re-entered, and the generator spun at 100% CPU
10+
//! and never produced a value — most visibly the `yaml` package's block-scalar
11+
//! / indicator lexer generators (`loop: while (true) { switch (this.charAt(0))
12+
//! { … continue loop } }`), which hung any `parse()` of a large minified
13+
//! bundle at module-init time.
14+
//!
15+
//! The unlabeled equivalent (`continue;`) always worked, because
16+
//! `switch_cases_have_loop_continue` detects a plain `Stmt::Continue`; only the
17+
//! labeled form was invisible to it. The fix rewrites `continue <label>` →
18+
//! plain `continue` when descending into a nested switch (a switch never
19+
//! captures `continue`), matching the unlabeled path.
20+
//!
21+
//! Expected outputs are byte-for-byte what `node --experimental-strip-types`
22+
//! prints. Each run is bounded by a timeout so a regression FAILS (with a
23+
//! spin) instead of hanging the test process.
24+
25+
use std::path::PathBuf;
26+
use std::process::Command;
27+
use std::time::{Duration, Instant};
28+
29+
fn perry_bin() -> PathBuf {
30+
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
31+
}
32+
33+
/// Compile `source`, run the binary with a wall-clock deadline, and return its
34+
/// stdout. Panics (fails the test) if compilation fails, the binary exits
35+
/// non-zero, or it does not finish within `timeout` (the pre-fix spin).
36+
fn compile_and_run(dir: &std::path::Path, source: &str) -> String {
37+
let entry = dir.join("main.ts");
38+
let output = dir.join("main_bin");
39+
std::fs::write(&entry, source).expect("write entry");
40+
41+
let compile = Command::new(perry_bin())
42+
.current_dir(dir)
43+
.arg("compile")
44+
.arg(&entry)
45+
.arg("-o")
46+
.arg(&output)
47+
.arg("--no-cache")
48+
.output()
49+
.expect("run perry compile");
50+
assert!(
51+
compile.status.success(),
52+
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
53+
String::from_utf8_lossy(&compile.stdout),
54+
String::from_utf8_lossy(&compile.stderr)
55+
);
56+
57+
let mut child = Command::new(&output)
58+
.current_dir(dir)
59+
.stdout(std::process::Stdio::piped())
60+
.stderr(std::process::Stdio::piped())
61+
.spawn()
62+
.expect("spawn compiled binary");
63+
64+
let timeout = Duration::from_secs(30);
65+
let start = Instant::now();
66+
loop {
67+
match child.try_wait().expect("try_wait") {
68+
Some(status) => {
69+
let out = child.wait_with_output().expect("wait_with_output");
70+
assert!(
71+
status.success(),
72+
"compiled binary failed (exit {:?})\nstdout:\n{}\nstderr:\n{}",
73+
status.code(),
74+
String::from_utf8_lossy(&out.stdout),
75+
String::from_utf8_lossy(&out.stderr)
76+
);
77+
return String::from_utf8_lossy(&out.stdout).into_owned();
78+
}
79+
None => {
80+
if start.elapsed() > timeout {
81+
let _ = child.kill();
82+
let _ = child.wait();
83+
panic!(
84+
"compiled binary did not finish within {:?} — the #5975 \
85+
labeled-continue-in-yielding-switch spin regressed",
86+
timeout
87+
);
88+
}
89+
std::thread::sleep(Duration::from_millis(50));
90+
}
91+
}
92+
}
93+
}
94+
95+
/// The minimal reproducer: a generator with a labeled `while (true)` whose
96+
/// yielding switch cases `continue loop`, and a `break loop` after the switch.
97+
#[test]
98+
fn generator_labeled_continue_in_yielding_switch_while() {
99+
let dir = tempfile::tempdir().expect("tempdir");
100+
let stdout = compile_and_run(
101+
dir.path(),
102+
r#"
103+
function* gen(input: string) {
104+
let i = 0, n = 0;
105+
loop: while (true) {
106+
switch (input[i]) {
107+
case 'a': yield 'A'; i++; n++; continue loop;
108+
case 'b': yield 'B'; i++; n++; continue loop;
109+
}
110+
break loop;
111+
}
112+
return n;
113+
}
114+
const out: string[] = [];
115+
const g = gen("aabbc");
116+
let r = g.next();
117+
while (!r.done) { out.push(r.value as string); r = g.next(); }
118+
console.log(out.join(",") + "|n=" + r.value);
119+
"#,
120+
);
121+
assert_eq!(stdout, "A,A,B,B|n=4\n");
122+
}
123+
124+
/// Same shape with a labeled `for (;;)` loop.
125+
#[test]
126+
fn generator_labeled_continue_in_yielding_switch_for() {
127+
let dir = tempfile::tempdir().expect("tempdir");
128+
let stdout = compile_and_run(
129+
dir.path(),
130+
r#"
131+
function* gen(input: string) {
132+
let n = 0;
133+
loop: for (let i = 0; ; ) {
134+
switch (input[i]) {
135+
case 'a': yield 'A'; i++; n++; continue loop;
136+
case 'b': yield 'B'; i++; n++; continue loop;
137+
}
138+
break loop;
139+
}
140+
return n;
141+
}
142+
const out: string[] = [];
143+
const g = gen("abbc");
144+
let r = g.next();
145+
while (!r.done) { out.push(r.value as string); r = g.next(); }
146+
console.log(out.join(",") + "|n=" + r.value);
147+
"#,
148+
);
149+
assert_eq!(stdout, "A,B,B|n=3\n");
150+
}
151+
152+
/// The async equivalent: `await` inside the switch case, then `continue loop`.
153+
#[test]
154+
fn async_labeled_continue_in_awaiting_switch_while() {
155+
let dir = tempfile::tempdir().expect("tempdir");
156+
let stdout = compile_and_run(
157+
dir.path(),
158+
r#"
159+
async function f(input: string) {
160+
let i = 0, n = 0, out = "";
161+
loop: while (true) {
162+
switch (input[i]) {
163+
case 'a': out += await Promise.resolve('A'); i++; n++; continue loop;
164+
case 'b': out += await Promise.resolve('B'); i++; n++; continue loop;
165+
}
166+
break loop;
167+
}
168+
return out + "|n=" + n;
169+
}
170+
f("aabbc").then((v) => console.log(v));
171+
"#,
172+
);
173+
assert_eq!(stdout, "AABB|n=4\n");
174+
}
175+
176+
/// A closer analogue of the `yaml` indicator lexer: the loop-continuing cases
177+
/// `yield*` a delegated generator before `continue loop`, and a `break loop`
178+
/// terminates when no case matches.
179+
#[test]
180+
fn generator_yield_delegate_then_labeled_continue() {
181+
let dir = tempfile::tempdir().expect("tempdir");
182+
let stdout = compile_and_run(
183+
dir.path(),
184+
r#"
185+
function* emit(tag: string, k: number) {
186+
for (let j = 0; j < k; j++) yield tag;
187+
}
188+
function* lex(src: string) {
189+
let i = 0, count = 0;
190+
loop: while (true) {
191+
switch (src[i]) {
192+
case '!':
193+
count += yield* emit("bang", 1);
194+
i++;
195+
continue loop;
196+
case '&':
197+
count += yield* emit("amp", 2);
198+
i++;
199+
continue loop;
200+
}
201+
break loop;
202+
}
203+
return count;
204+
}
205+
const out: string[] = [];
206+
const g = lex("!&!x");
207+
let r = g.next();
208+
while (!r.done) { out.push(r.value as string); r = g.next(); }
209+
console.log(out.join(",") + "|count=" + r.value);
210+
"#,
211+
);
212+
// `emit` yields the tag string; `count += yield* emit(...)` adds the
213+
// generator's return value (undefined -> NaN in JS), matching node.
214+
assert_eq!(stdout, "bang,amp,amp,bang|count=NaN\n");
215+
}

0 commit comments

Comments
 (0)