From e00ae911aa23dbff2a61342ccf7a8da7ab7249de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 3 Jul 2026 11:27:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(transform):=20#5868=20=E2=80=94=20switc?= =?UTF-8?q?h=20inside=20async/generator=20state=20machines=20miscompiled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, one shared fix: 1. yield/await inside a case body was silently lowered to 0: the linearizer had no Stmt::Switch arm, so the switch fell to the catch-all, was emitted unsplit inside one state, and codegen's fallback lowered the residual Expr::Yield to 0.0. async f(x){ switch(x){ case 1: return await g() } } resolved to 0 without suspending; a generator yield inside a case vanished. 2. a loop-level continue inside a yield-free switch in a CPS'd loop survived as a raw Stmt::Continue the dispatch loop ignored, so the iteration's remainder ran anyway. New desugar_switch_to_ifs (break_continue.rs): discriminant temp + match-index + guarded-if chain preserving JS switch semantics (single-eval discriminant, in-order first-match-wins tests, fallthrough, default-in-the-middle, break — incl. from nested if/try — via done-flag with remainder guarding). Wired into (a) a new yielding-switch arm in linearize_body, (b) a splice in rewrite_break_continue_in_stmts for continue-bearing yield-free switches (next_local_id threaded through), and (c) the labeled arm maps 'break label' to plain break inside a labeled switch's cases. e2e: crates/perry/tests/issue_5868_switch_state_machine.rs (9 scenarios byte-for-byte vs node --experimental-strip-types). cargo test -p perry-transform: 45 passed. --- .../src/generator/break_continue.rs | 329 +++++++++++++++++- .../src/generator/hoist_yields.rs | 2 +- .../src/generator/linearize.rs | 51 ++- .../tests/issue_5868_switch_state_machine.rs | 293 ++++++++++++++++ 4 files changed, 657 insertions(+), 18 deletions(-) create mode 100644 crates/perry/tests/issue_5868_switch_state_machine.rs diff --git a/crates/perry-transform/src/generator/break_continue.rs b/crates/perry-transform/src/generator/break_continue.rs index d277ceec52..b9d426d204 100644 --- a/crates/perry-transform/src/generator/break_continue.rs +++ b/crates/perry-transform/src/generator/break_continue.rs @@ -17,9 +17,18 @@ const CONTINUE_SENTINEL: f64 = 1_000_002.0; /// into `[LocalSet(state_id, ), Stmt::Continue]`. The trailing /// `Stmt::Continue` is the state-machine's dispatch-loop continue, which /// re-enters the while(true) and re-dispatches on the new state. Stops at -/// nested loop / switch / closure boundaries — their own break/continue -/// belong to those constructs, not to us. -pub fn rewrite_break_continue_in_stmts(stmts: &mut Vec, state_id: LocalId) { +/// nested loop / closure boundaries — their own break/continue belong to +/// those constructs, not to us. A nested `switch` captures `break` but +/// NEVER `continue`: a switch whose cases carry a loop-level `continue` is +/// desugared into plain `if`s first (#5868 — previously the raw +/// `Stmt::Continue` survived verbatim inside the switch in the state body +/// and the dispatch lowering silently ignored it, so the rest of the loop +/// iteration ran anyway). +pub fn rewrite_break_continue_in_stmts( + stmts: &mut Vec, + state_id: LocalId, + next_local_id: &mut u32, +) { let mut i = 0; while i < stmts.len() { let stmt = std::mem::replace(&mut stmts[i], Stmt::Continue); @@ -40,8 +49,21 @@ pub fn rewrite_break_continue_in_stmts(stmts: &mut Vec, state_id: LocalId) stmts.insert(i + 1, Stmt::Continue); i += 2; } + Stmt::Switch { + discriminant, + cases, + } if switch_cases_have_loop_continue(&cases) => { + // Replace the switch (currently a placeholder after the + // mem::replace) with its if-chain desugar and reprocess from + // the same index: the desugared statements are plain `if`s, + // so this rewriter descends them and converts the loop-level + // `continue`s to sentinels; `break`s were already folded + // into the desugar's done-flag. + let desugared = desugar_switch_to_ifs(&discriminant, &cases, next_local_id); + stmts.splice(i..=i, desugared); + } mut other => { - rewrite_break_continue_in_stmt(&mut other, state_id); + rewrite_break_continue_in_stmt(&mut other, state_id, next_local_id); stmts[i] = other; i += 1; } @@ -49,16 +71,16 @@ pub fn rewrite_break_continue_in_stmts(stmts: &mut Vec, state_id: LocalId) } } -pub fn rewrite_break_continue_in_stmt(stmt: &mut Stmt, state_id: LocalId) { +pub fn rewrite_break_continue_in_stmt(stmt: &mut Stmt, state_id: LocalId, next_local_id: &mut u32) { match stmt { Stmt::If { then_branch, else_branch, .. } => { - rewrite_break_continue_in_stmts(then_branch, state_id); + rewrite_break_continue_in_stmts(then_branch, state_id, next_local_id); if let Some(eb) = else_branch.as_mut() { - rewrite_break_continue_in_stmts(eb, state_id); + rewrite_break_continue_in_stmts(eb, state_id, next_local_id); } } Stmt::Try { @@ -66,19 +88,22 @@ pub fn rewrite_break_continue_in_stmt(stmt: &mut Stmt, state_id: LocalId) { catch, finally, } => { - rewrite_break_continue_in_stmts(body, state_id); + rewrite_break_continue_in_stmts(body, state_id, next_local_id); if let Some(c) = catch.as_mut() { - rewrite_break_continue_in_stmts(&mut c.body, state_id); + rewrite_break_continue_in_stmts(&mut c.body, state_id, next_local_id); } if let Some(f) = finally.as_mut() { - rewrite_break_continue_in_stmts(f, state_id); + rewrite_break_continue_in_stmts(f, state_id, next_local_id); } } - // Inside nested loops / switch / labeled / closure expressions, the - // user's `break`/`continue` belongs to that construct and not to the - // outer loop the state machine is unrolling. Leave them as-is so the - // inner linearize_body (if it yields) / regular codegen (if it - // doesn't) handles them. + // Inside nested loops / closure expressions, the user's + // `break`/`continue` belongs to that construct and not to the outer + // loop the state machine is unrolling. Leave them as-is so the inner + // linearize_body (if it yields) / regular codegen (if it doesn't) + // handles them. A `switch` reaching here carries no loop-level + // `continue` (the stmts-level pass desugared those), and its + // `break`s bind to the switch itself. `Labeled` is left as-is + // (pre-existing single-sentinel limitation). Stmt::For { .. } | Stmt::While { .. } | Stmt::DoWhile { .. } => {} Stmt::Switch { .. } => {} Stmt::Labeled { .. } => {} @@ -386,3 +411,277 @@ pub fn collect_vars_recursive(stmts: &[Stmt], vars: &mut Vec<(LocalId, String, T } } } + +// --------------------------------------------------------------------------- +// #5868: switch desugaring for state-machine bodies +// --------------------------------------------------------------------------- + +/// Does any case body carry a `continue` that binds to the ENCLOSING LOOP +/// (i.e. at switch-case level, or nested only through `if`/`try`/inner +/// `switch` — all constructs that do not capture `continue`)? Loops and +/// labeled statements capture their own `continue`s, so descent stops there. +fn switch_cases_have_loop_continue(cases: &[SwitchCase]) -> bool { + cases + .iter() + .any(|c| stmts_have_loop_level_continue(&c.body)) +} + +fn stmts_have_loop_level_continue(stmts: &[Stmt]) -> bool { + stmts.iter().any(|s| match s { + Stmt::Continue => true, + Stmt::If { + then_branch, + else_branch, + .. + } => { + stmts_have_loop_level_continue(then_branch) + || else_branch + .as_ref() + .is_some_and(|e| stmts_have_loop_level_continue(e)) + } + Stmt::Try { + body, + catch, + finally, + } => { + stmts_have_loop_level_continue(body) + || catch + .as_ref() + .is_some_and(|c| stmts_have_loop_level_continue(&c.body)) + || finally + .as_ref() + .is_some_and(|f| stmts_have_loop_level_continue(f)) + } + Stmt::Switch { cases, .. } => switch_cases_have_loop_continue(cases), + _ => false, + }) +} + +/// Desugar a `switch` into an equivalent match-index + guarded-`if` chain +/// (#5868). Used in two places: +/// +/// 1. `linearize_body`'s yielding-switch arm — a `yield`/`await` inside a +/// case body previously fell through to the catch-all, was emitted +/// unsplit inside one state, and codegen lowered the residual +/// `Expr::Yield` to `0.0`. +/// 2. `rewrite_break_continue_in_stmts` — a loop-level `continue` inside +/// a (yield-free) switch in a linearized loop body previously survived +/// as a raw `Stmt::Continue` the dispatch loop ignored. +/// +/// Shape (JS switch semantics preserved): +/// +/// ```text +/// __sw_d = ; // evaluated exactly once +/// __sw_idx = UNMATCHED; +/// // case tests, evaluated only while still unmatched (first match wins; +/// // spec order == source order of the non-default clauses): +/// if (__sw_idx === UNMATCHED) { __sw_t = ; if (__sw_d === __sw_t) __sw_idx = i; } +/// ... +/// if (__sw_idx === UNMATCHED) __sw_idx = ; +/// __sw_done = false; +/// // bodies in POSITIONAL order — `__sw_idx <= i` gives fallthrough; +/// // `break` becomes `__sw_done = true` plus remainder-guarding: +/// if (!__sw_done && __sw_idx <= i) { } +/// ... +/// ``` +/// +/// `continue` / `return` / `throw` in case bodies pass through untouched — +/// after the desugar they sit in plain `if`s, where the loop machinery (or +/// function-level lowering) handles them normally. Fresh locals follow the +/// DoWhile-flag pattern (plain `LocalSet` on an `alloc_local` id; generator +/// local persistence carries them across suspend states). +pub fn desugar_switch_to_ifs( + discriminant: &Expr, + cases: &[SwitchCase], + next_local_id: &mut u32, +) -> Vec { + let n = cases.len(); + let unmatched = (n + 1) as f64; + let default_pos = cases.iter().position(|c| c.test.is_none()); + let start_when_unmatched = default_pos.unwrap_or(n) as f64; + + let d_id = alloc_local(next_local_id); + let idx_id = alloc_local(next_local_id); + let done_id = alloc_local(next_local_id); + + let idx_is_unmatched = || Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(idx_id)), + right: Box::new(Expr::Number(unmatched)), + }; + + let mut out = Vec::with_capacity(2 * n + 4); + out.push(Stmt::Expr(Expr::LocalSet( + d_id, + Box::new(discriminant.clone()), + ))); + out.push(Stmt::Expr(Expr::LocalSet( + idx_id, + Box::new(Expr::Number(unmatched)), + ))); + + // Tests in source order over the non-default clauses — identical to the + // spec's pre-default-then-post-default order, since the default clause + // contributes no test. Each test evaluates only while unmatched, so + // side-effecting tests after the first match are (correctly) skipped. + for (i, case) in cases.iter().enumerate() { + let Some(test) = &case.test else { continue }; + let t_id = alloc_local(next_local_id); + out.push(Stmt::If { + condition: idx_is_unmatched(), + then_branch: vec![ + Stmt::Expr(Expr::LocalSet(t_id, Box::new(test.clone()))), + Stmt::If { + condition: Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(d_id)), + right: Box::new(Expr::LocalGet(t_id)), + }, + then_branch: vec![Stmt::Expr(Expr::LocalSet( + idx_id, + Box::new(Expr::Number(i as f64)), + ))], + else_branch: None, + }, + ], + else_branch: None, + }); + } + out.push(Stmt::If { + condition: idx_is_unmatched(), + then_branch: vec![Stmt::Expr(Expr::LocalSet( + idx_id, + Box::new(Expr::Number(start_when_unmatched)), + ))], + else_branch: None, + }); + out.push(Stmt::Expr(Expr::LocalSet( + done_id, + Box::new(Expr::Bool(false)), + ))); + + for (i, case) in cases.iter().enumerate() { + let mut guarded = Vec::new(); + guard_switch_breaks(&case.body, done_id, &mut guarded); + out.push(Stmt::If { + condition: Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Unary { + op: UnaryOp::Not, + operand: Box::new(Expr::LocalGet(done_id)), + }), + right: Box::new(Expr::Compare { + op: CompareOp::Le, + left: Box::new(Expr::LocalGet(idx_id)), + right: Box::new(Expr::Number(i as f64)), + }), + }, + then_branch: guarded, + else_branch: None, + }); + } + out +} + +/// Copy a case body into `out`, rewriting every `break` that binds to the +/// switch being desugared into `__sw_done = true`, and guarding every +/// statement that follows a potentially-breaking statement behind +/// `if (!__sw_done)`. Descends `if`/`try` (which don't capture `break`); +/// stops at nested loops, switches, and labeled statements (whose `break` +/// binds to themselves). Statements directly after a bare `break` are +/// unreachable and dropped. +fn guard_switch_breaks(stmts: &[Stmt], done_id: LocalId, out: &mut Vec) { + let mut i = 0; + while i < stmts.len() { + let s = &stmts[i]; + if matches!(s, Stmt::Break) { + out.push(Stmt::Expr(Expr::LocalSet( + done_id, + Box::new(Expr::Bool(true)), + ))); + return; + } + let may_break = stmt_may_break_switch(s); + out.push(rewrite_switch_breaks_in_stmt(s, done_id)); + i += 1; + if may_break && i < stmts.len() { + let mut rest = Vec::new(); + guard_switch_breaks(&stmts[i..], done_id, &mut rest); + out.push(Stmt::If { + condition: Expr::Unary { + op: UnaryOp::Not, + operand: Box::new(Expr::LocalGet(done_id)), + }, + then_branch: rest, + else_branch: None, + }); + return; + } + } +} + +/// Can executing this statement hit a `break` that binds to the switch +/// being desugared? Mirrors `guard_switch_breaks`'s descent scoping. +fn stmt_may_break_switch(s: &Stmt) -> bool { + match s { + Stmt::Break => true, + Stmt::If { + then_branch, + else_branch, + .. + } => { + then_branch.iter().any(stmt_may_break_switch) + || else_branch + .as_ref() + .is_some_and(|e| e.iter().any(stmt_may_break_switch)) + } + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().any(stmt_may_break_switch) + || catch + .as_ref() + .is_some_and(|c| c.body.iter().any(stmt_may_break_switch)) + || finally + .as_ref() + .is_some_and(|f| f.iter().any(stmt_may_break_switch)) + } + _ => false, + } +} + +/// Rebuild one statement with switch-binding `break`s rewritten (via +/// `guard_switch_breaks`) inside its `if`/`try` sub-bodies. +fn rewrite_switch_breaks_in_stmt(s: &Stmt, done_id: LocalId) -> Stmt { + let guarded = |body: &[Stmt]| { + let mut v = Vec::new(); + guard_switch_breaks(body, done_id, &mut v); + v + }; + match s { + Stmt::If { + condition, + then_branch, + else_branch, + } => Stmt::If { + condition: condition.clone(), + then_branch: guarded(then_branch), + else_branch: else_branch.as_ref().map(|e| guarded(e)), + }, + Stmt::Try { + body, + catch, + finally, + } => Stmt::Try { + body: guarded(body), + catch: catch.as_ref().map(|c| CatchClause { + param: c.param.clone(), + body: guarded(&c.body), + }), + finally: finally.as_ref().map(|f| guarded(f)), + }, + other => other.clone(), + } +} diff --git a/crates/perry-transform/src/generator/hoist_yields.rs b/crates/perry-transform/src/generator/hoist_yields.rs index 0e6793b2f7..7d19a9679b 100644 --- a/crates/perry-transform/src/generator/hoist_yields.rs +++ b/crates/perry-transform/src/generator/hoist_yields.rs @@ -247,7 +247,7 @@ fn logical_rhs_contains_yield(expr: &Expr) -> bool { false } -fn expr_contains_yield(expr: &Expr) -> bool { +pub(super) fn expr_contains_yield(expr: &Expr) -> bool { if matches!(expr, Expr::Yield { .. }) { return true; } diff --git a/crates/perry-transform/src/generator/linearize.rs b/crates/perry-transform/src/generator/linearize.rs index b96313d1f1..89ad71074f 100644 --- a/crates/perry-transform/src/generator/linearize.rs +++ b/crates/perry-transform/src/generator/linearize.rs @@ -643,7 +643,7 @@ pub fn linearize_body( let body_current_before = current.len(); let body_catches_before = catches.len(); let mut body_rewritten = body.clone(); - rewrite_break_continue_in_stmts(&mut body_rewritten, state_id); + rewrite_break_continue_in_stmts(&mut body_rewritten, state_id, next_local_id); // Process loop body (may contain yields) linearize_body( @@ -779,7 +779,7 @@ pub fn linearize_body( let while_current_before = current.len(); let while_catches_before = catches.len(); let mut while_body_rewritten = while_body.clone(); - rewrite_break_continue_in_stmts(&mut while_body_rewritten, state_id); + rewrite_break_continue_in_stmts(&mut while_body_rewritten, state_id, next_local_id); // Process body linearize_body( @@ -1319,6 +1319,15 @@ pub fn linearize_body( | Stmt::DoWhile { body, .. } => { rewrite_labeled_bc_in_stmts(body, label); } + // A labeled yielding SWITCH: `break label` at case-body + // level is the switch's own break — rewrite it to plain + // `break` so the yielding-switch desugar below folds it + // into the done-flag (#5868). + Stmt::Switch { cases, .. } => { + for case in cases.iter_mut() { + rewrite_labeled_bc_in_stmts(&mut case.body, label); + } + } _ => {} } linearize_body( @@ -1334,6 +1343,44 @@ pub fn linearize_body( ); } + // `switch` containing yield(s) — in a case body, a case test, or + // the discriminant: desugar into a match-index + guarded-`if` + // chain (see `desugar_switch_to_ifs`) and recurse, so the + // existing `If` linearization splits the yield into resume + // states. Previously this fell through to the catch-all: the + // switch was emitted unsplit inside one state and codegen + // lowered the embedded residual `Expr::Yield` to `0.0` — + // `async f(x){ switch(x){ case 1: return await g() } }` resolved + // to `0` without ever suspending (#5868). + Stmt::Switch { + discriminant, + cases, + } if super::hoist_yields::expr_contains_yield(discriminant) + || cases.iter().any(|c| { + body_contains_yield(&c.body) + || c.test + .as_ref() + .is_some_and(super::hoist_yields::expr_contains_yield) + }) => + { + let desugared = super::break_continue::desugar_switch_to_ifs( + discriminant, + cases, + next_local_id, + ); + linearize_body( + &desugared, + states, + current, + state_num, + state_id, + next_local_id, + sent_id, + catches, + finallys, + ); + } + // Regular statement (no yield) - accumulate other => { current.push(other.clone()); diff --git a/crates/perry/tests/issue_5868_switch_state_machine.rs b/crates/perry/tests/issue_5868_switch_state_machine.rs new file mode 100644 index 0000000000..0785010c80 --- /dev/null +++ b/crates/perry/tests/issue_5868_switch_state_machine.rs @@ -0,0 +1,293 @@ +//! Regression tests for #5868: `switch` inside async/generator state +//! machines miscompiled. +//! +//! The linearizer had no `Stmt::Switch` arm, so a switch whose case body +//! contained a `yield`/`await` fell to the catch-all, was emitted unsplit +//! inside one state, and codegen lowered the embedded residual `Expr::Yield` +//! to `0.0` — `async f(x){ switch(x){ case 1: return await g() } }` +//! resolved to `0` without suspending, and a generator `yield` inside a +//! case vanished. Separately, a loop-level `continue` inside a (yield-free) +//! switch in a CPS'd loop survived as a raw `Stmt::Continue` the dispatch +//! loop ignored. +//! +//! Both now route through `desugar_switch_to_ifs` (match-index + guarded +//! `if` chain), which preserves JS switch semantics: discriminant evaluated +//! once, case tests evaluated in order only until the first match, +//! fallthrough, default-in-the-middle, `break` (including from nested +//! `if`/`try`), and loop-level `continue`. +//! +//! All expected outputs are byte-for-byte what `node +//! --experimental-strip-types` prints. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed (exit {:?})\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// Issue repro 1: `return await` inside a case resolved to `0`. +#[test] +fn return_await_inside_case() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +async function pick(x: number) { + switch (x) { + case 1: + return await Promise.resolve("one"); + } + return "other"; +} +pick(1).then((v) => console.log("got:", v)); +"#, + ); + assert_eq!(stdout, "got: one\n"); +} + +/// Issue repro 2: assignment-form await inside a case resolved to `0`. +#[test] +fn assign_await_inside_case() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +async function pick2(x: number) { + let out = "other"; + switch (x) { + case 1: { + out = await Promise.resolve("uno"); + break; + } + } + return out; +} +pick2(1).then((v) => console.log("got2:", v)); +"#, + ); + assert_eq!(stdout, "got2: uno\n"); +} + +/// Issue repro 3: a generator `yield` inside a case vanished. +#[test] +fn generator_yield_inside_case() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +function* g(x: number) { + switch (x) { + case 1: + yield "a"; + } + yield "b"; +} +console.log([...g(1)].join(",")); +"#, + ); + assert_eq!(stdout, "a,b\n"); +} + +/// Issue repro 4: a loop-level `continue` inside a yield-free switch in a +/// CPS'd loop was silently ignored (the iteration's remainder ran anyway). +#[test] +fn continue_inside_switch_in_async_loop() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +async function loop() { + let i = 0; + let hits = 0; + while (i < 3) { + i++; + await Promise.resolve(); + switch (i) { + case 1: + continue; + } + hits++; + } + return hits; +} +loop().then((h) => console.log("hits:", h)); +"#, + ); + assert_eq!(stdout, "hits: 2\n"); +} + +/// Fallthrough across awaits: matched case falls into the next case's body +/// until the `break`. +#[test] +fn fallthrough_across_awaits() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +async function run() { + const out: string[] = []; + switch (1) { + case 1: + out.push(await Promise.resolve("A")); + case 2: + out.push(await Promise.resolve("B")); + break; + case 3: + out.push("C"); + } + return out.join(""); +} +run().then((v) => console.log("fall:", v)); +"#, + ); + assert_eq!(stdout, "fall: AB\n"); +} + +/// Default in the MIDDLE with no matching case: execution starts at the +/// default clause and falls through the clauses after it. +#[test] +fn default_in_the_middle_no_match() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +async function run(x: number) { + const out: string[] = []; + switch (x) { + case 1: + out.push(await Promise.resolve("one")); + break; + default: + out.push(await Promise.resolve("dflt")); + case 2: + out.push(await Promise.resolve("two")); + break; + case 3: + out.push("three"); + } + return out.join(","); +} +run(9).then((v) => console.log("mid-default:", v)); +"#, + ); + assert_eq!(stdout, "mid-default: dflt,two\n"); +} + +/// Case tests are evaluated in order, exactly once each, and only until the +/// first match — a side-effecting test after the match must NOT run. +#[test] +fn case_tests_evaluate_in_order_until_match() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +function t(n: number): number { + console.log("test", n); + return n; +} +async function run() { + switch (2) { + case t(1): + console.log("b1"); + case t(2): + console.log("b2"); + await Promise.resolve(); + console.log("b2b"); + break; + case t(3): + console.log("b3"); + } + console.log("after"); +} +run(); +"#, + ); + assert_eq!(stdout, "test 1\ntest 2\nb2\nb2b\nafter\n"); +} + +/// `break` from inside an `if` in a case body must abort the rest of the +/// case AND the fallthrough; without the break, both continue. +#[test] +fn break_inside_if_in_case_body() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +async function run(c: boolean) { + const out: string[] = []; + switch (1) { + case 1: + out.push(await Promise.resolve("x")); + if (c) break; + out.push("more"); + case 2: + out.push("fall"); + } + return out.join(","); +} +run(true).then((v) => console.log("brk-if-t:", v)); +run(false).then((v) => console.log("brk-if-f:", v)); +"#, + ); + assert_eq!(stdout, "brk-if-t: x\nbrk-if-f: x,more,fall\n"); +} + +/// A LABELED switch with `break label` from a case body containing an +/// await: the labeled break is the switch's own break. +#[test] +fn labeled_switch_break_label() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +async function run() { + l: switch (1) { + case 1: + console.log(await Promise.resolve("in-case")); + break l; + case 2: + console.log("no"); + } + console.log("after"); +} +run(); +"#, + ); + assert_eq!(stdout, "in-case\nafter\n"); +} From cf94e71ec6d2b7f1244fb787bcdff4e8ca062208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 3 Jul 2026 11:47:11 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=20v0.5.1217=20=E2=80=94=20v?= =?UTF-8?q?ersion=20bump=20+=20changelog=20for=20#5885?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++ CLAUDE.md | 2 +- Cargo.lock | 152 +++++++++++++++++++++++++-------------------------- Cargo.toml | 2 +- 4 files changed, 82 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d87113587e..894c49d90e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## v0.5.1217 — switch inside async/generator state machines miscompiled (#5885, fixes #5868) + +Two state-machine defects, one shared fix. (1) A `yield`/`await` inside a switch case was silently lowered to `0`: the linearizer had no `Stmt::Switch` arm, so the switch fell to the catch-all, was emitted unsplit inside one state, and codegen's fallback lowered the residual `Expr::Yield` to `0.0` — `async f(x){ switch(x){ case 1: return await g() } }` resolved to `0` without suspending, and a generator `yield` inside a case vanished. (2) A loop-level `continue` inside a yield-free switch in a CPS'd loop survived as a raw `Stmt::Continue` the dispatch loop ignored, silently running the rest of the iteration. Both live in production paths since #5854 put async class methods through CPS; minifiers turn if-chains into switches routinely. New `desugar_switch_to_ifs` (generator/break_continue.rs): discriminant temp + match-index + guarded-`if` chain preserving JS switch semantics (single-eval discriminant, in-order first-match-wins tests, fallthrough, default-in-the-middle, `break` — incl. from nested if/try — via done-flag with remainder guarding; `continue`/`return`/`throw` pass through). Wired into a new yielding-switch `linearize_body` arm, a splice in `rewrite_break_continue_in_stmts` for continue-bearing yield-free switches (`next_local_id` threaded through), and the labeled arm (`break label` → plain break in a labeled switch). e2e: `crates/perry/tests/issue_5868_switch_state_machine.rs` (9 scenarios vs node). Note: this PR's labeled run showed parity/smokes/doc-tests failing to BUILD with the pre-existing #5466 E0308 feature-combo breakage (#5872) — zero parity tests executed; compiler-output-regression and the full cargo-test suite are green. + ## v0.5.1216 — promise reactions must not clobber the single handler slot (#5867) A promise carries one inline reaction slot (`on_fulfilled`/`on_rejected`/`next`); `js_promise_then` diverted 2nd+ reactions to the overflow table, but two other attach paths stored unconditionally: `js_promise_attach_handlers` (Promise.all/allSettled/race/any via `promise_resolve_for_combinator`, stream adapters) destroyed any earlier reaction — `p.then(cb); Promise.all([p])` lost `cb`, and two combinators sharing one pending input destroyed each other's forwarder so the loser's remaining-count never reached zero (permanent hang; the shared webpack/Turbopack chunk-promise shape, #5437 family) — and `js_promise_finally` on a PENDING promise overwrote the slot and nulled `promise.next`. All three occupancy checks (including `js_promise_then` itself) now treat a non-null `next` as occupied too: a degenerate no-arg `p.then()` parks with both closures null and only `next` set, and the spec combinators attach per-element reactions through `js_promise_then` via `invoke_then`, so `const c = p.then(); Promise.all([p])` stranded `c`'s chain. Also carries the #5437-branch fix: `.finally()` on a SETTLED promise dispatches its own wrapper exactly once via `Task::Inline` (previously N settled finallys ran the last wrapper N times — Turbopack `loadChunkAsync` / Next.js CacheSignal), and settled `.finally` now captures the attach-time async context. e2e: `crates/perry/tests/promise_reaction_slot_overflow.rs` (7 scenarios byte-for-byte vs `node --experimental-strip-types`). `promise/then.rs` (2008 lines) is allowlisted 8-over the file-size gate; the topical split of its #1545 value-read-thunk tail is a tracked follow-up. diff --git a/CLAUDE.md b/CLAUDE.md index eca75dd312..bbc3350085 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1216 +**Current Version:** 0.5.1217 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 3dc543e6e2..08e73b8dc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5360,7 +5360,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "base64", @@ -5417,14 +5417,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "cc", "libc", @@ -5432,7 +5432,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "log", @@ -5447,7 +5447,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "perry-hir", @@ -5456,7 +5456,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "perry-hir", @@ -5464,7 +5464,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "perry-dispatch", @@ -5474,7 +5474,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "perry-hir", @@ -5483,7 +5483,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "base64", @@ -5496,7 +5496,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "perry-hir", @@ -5504,7 +5504,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "async-trait", @@ -5533,14 +5533,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "serde", "serde_json", @@ -5548,7 +5548,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1216" +version = "0.5.1217" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5559,7 +5559,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "clap", @@ -5574,7 +5574,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "block2", "objc2", @@ -5584,7 +5584,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "argon2", "perry-ffi", @@ -5592,7 +5592,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "reqwest", @@ -5601,7 +5601,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "bcrypt", "perry-ffi", @@ -5609,7 +5609,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "rusqlite", @@ -5617,7 +5617,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "scraper", @@ -5625,7 +5625,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "perry-runtime", @@ -5633,7 +5633,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "chrono", "cron", @@ -5643,7 +5643,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "chrono", "perry-ffi", @@ -5651,7 +5651,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "rust_decimal", @@ -5659,7 +5659,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "serde_json", @@ -5667,7 +5667,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "perry-runtime", @@ -5683,14 +5683,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "bytes", "http-body-util", @@ -5708,7 +5708,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "bytes", "lazy_static", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "bytes", "lazy_static", @@ -5735,7 +5735,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "bytes", "h2", @@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "lazy_static", "perry-ffi", @@ -5768,7 +5768,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "jsonwebtoken", @@ -5779,7 +5779,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "lru", "perry-ffi", @@ -5787,7 +5787,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "chrono", "perry-ffi", @@ -5795,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "bson 3.1.0", "futures-util", @@ -5807,7 +5807,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "chrono", "perry-ffi", @@ -5817,7 +5817,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "nanoid", "perry-ffi", @@ -5826,7 +5826,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "bytes", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "lettre", "perry-ffi", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "printpdf", @@ -5857,7 +5857,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "sqlx", @@ -5866,7 +5866,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "governor", "perry-ffi", @@ -5874,7 +5874,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "fast_image_resize", "image", @@ -5884,14 +5884,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "lazy_static", "perry-ffi", @@ -5900,7 +5900,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "uuid", @@ -5908,7 +5908,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ffi", "regex", @@ -5918,7 +5918,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "futures-util", "lazy_static", @@ -5930,7 +5930,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "brotli", "flate2", @@ -5939,7 +5939,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "dashmap", "once_cell", @@ -5948,7 +5948,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "perry-api-manifest", @@ -5966,7 +5966,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "perry-diagnostics", @@ -5978,7 +5978,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "base64", @@ -6011,14 +6011,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6110,14 +6110,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "perry-hir", @@ -6127,7 +6127,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6135,14 +6135,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "itoa", @@ -6159,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "rand 0.8.6", "serde", @@ -6169,7 +6169,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6192,7 +6192,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "block2", @@ -6208,7 +6208,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "block2", @@ -6223,7 +6223,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1216" +version = "0.5.1217" [[package]] name = "perry-ui-test" @@ -6231,11 +6231,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1216" +version = "0.5.1217" [[package]] name = "perry-ui-tvos" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "block2", @@ -6251,7 +6251,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "block2", @@ -6267,7 +6267,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "block2", "libc", @@ -6280,7 +6280,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "libc", @@ -6297,14 +6297,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "base64", "ed25519-dalek", @@ -6318,7 +6318,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1216" +version = "0.5.1217" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index e7578cc28f..6a364223fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -301,7 +301,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1216" +version = "0.5.1217" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"