From af3410cf19013844d5025304f449616d8a12f91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 17:27:59 +0200 Subject: [PATCH 01/17] =?UTF-8?q?perf(hir):=20O(1)=20closure-capture=20ana?= =?UTF-8?q?lysis=20=E2=80=94=20incremental=20id=5Fset=20+=20shared=20write?= =?UTF-8?q?-scan=20shadow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compute_closure_captures rebuilt an O(scope) membership set per closure (O(n^2) for N closures in an N-binding scope). Maintain a live id_set on Locals (insert/remove/reindex) and pass it by reference; share the fn_ctor_env write-scan Shadow instead of cloning per nested fn. cap_12000 check: 13.06s -> 0.07s. Captures/mutable-captures semantics unchanged (param/inner-decl/dayjs same-id filtering preserved). --- crates/perry-hir/src/lower/expr_function.rs | 54 ++++++------- crates/perry-hir/src/lower/fn_ctor_env.rs | 75 ++++++++++++++----- crates/perry-hir/src/lower/locals.rs | 30 +++++++- .../lower_decl/body_stmt/nested_fn_decl.rs | 16 ++-- 4 files changed, 119 insertions(+), 56 deletions(-) diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 592d7908ad..71b8b3a7a9 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -219,13 +219,6 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> .unwrap_or_default(); ctx.enter_type_param_scope(&arrow_type_params); - // Track which locals exist before entering the closure scope - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); - // Lower parameters and collect destructuring info let mut params = Vec::new(); let mut destructuring_params: Vec<(LocalId, ast::Pat)> = Vec::new(); @@ -413,7 +406,11 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> // arrows don't leak outer T/U bindings into sibling code. ctx.exit_type_param_scope(); - let (captures, mutable_captures) = compute_closure_captures(ctx, &body, &outer_locals, ¶ms); + // The closure's own scope has been popped, so `ctx.locals.id_set()` is now + // exactly the enclosing scope's live locals — the membership view capture + // analysis needs. (Previously rebuilt per closure from a cloned snapshot.) + let (captures, mutable_captures) = + compute_closure_captures(ctx, &body, ctx.locals.id_set(), ¶ms); // Check if this arrow function uses `this` (needs to capture it from enclosing scope) let captures_this = closure_uses_this(&body); @@ -501,11 +498,15 @@ fn lower_named_fn_expr( // what the wrapper itself captures and threads through to the inner // function. let wrapper_scope = ctx.enter_scope(); - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); + // Snapshot the enclosing locals *before* the self-binding is added, so the + // wrapper captures them (not the self-binding). Unlike the arrow/fn-expr + // paths, capture analysis runs here while the wrapper scope is still open + // (the self-binding lives in it), so we can't use `ctx.locals.id_set()` — + // it would wrongly include `self_id`. This is a rare path (named function + // expressions that recursively self-reference), so an explicit snapshot is + // fine. + let outer_local_ids: std::collections::HashSet = + ctx.locals.iter().map(|(_, id, _)| *id).collect(); let self_id = ctx.define_local(own_name.clone(), Type::Any); // Lower the function itself as an anonymous closure. With `self_id` @@ -533,7 +534,8 @@ fn lower_named_fn_expr( }, Stmt::Return(Some(Expr::LocalGet(self_id))), ]; - let (captures, mutable_captures) = compute_closure_captures(ctx, &body, &outer_locals, &[]); + let (captures, mutable_captures) = + compute_closure_captures(ctx, &body, &outer_local_ids, &[]); ctx.exit_scope(wrapper_scope); Ok(Expr::Call { @@ -577,13 +579,6 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul let saved_field_init = ctx.in_class_field_init; ctx.in_class_field_init = false; - // Track which locals exist before entering the closure scope - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); - // Lower parameters and collect destructuring info. // // Refs #915 (gap 1 from #899 — Effect's `dual(arity, body)`): TypeScript's @@ -1038,7 +1033,9 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul ctx.exit_scope(scope_mark); ctx.in_class_field_init = saved_field_init; - let (captures, mutable_captures) = compute_closure_captures(ctx, &body, &outer_locals, ¶ms); + // Scope popped: `ctx.locals.id_set()` is now the enclosing scope's locals. + let (captures, mutable_captures) = + compute_closure_captures(ctx, &body, ctx.locals.id_set(), ¶ms); // #2076: a named function expression's own ident is its `fn.name` // per spec, regardless of the binding identifier it's later assigned @@ -1081,7 +1078,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul fn compute_closure_captures( ctx: &LoweringContext, body: &[Stmt], - outer_locals: &[(String, LocalId)], + outer_local_ids: &std::collections::HashSet, params: &[Param], ) -> (Vec, Vec) { // Detect captured variables: locals referenced in the body that @@ -1092,10 +1089,13 @@ fn compute_closure_captures( collect_local_refs_stmt(stmt, &mut all_refs, &mut visited_closures); } - // Filter to only include outer locals (not parameters or locals - // defined within the closure). - let outer_local_ids: std::collections::HashSet = - outer_locals.iter().map(|(_, id)| *id).collect(); + // `outer_local_ids` is the membership view of the enclosing scope's + // locals, supplied by the caller (the live `ctx.locals.id_set()` once the + // closure's own scope has been popped). Previously this was rebuilt into a + // fresh `HashSet` from an `&[(String, LocalId)]` snapshot on *every* + // closure — O(scope) per closure, i.e. O(n²) over n sibling closures in a + // large scope. We only ever need membership tests here, so the caller's + // incrementally-maintained set is reused directly. let param_ids: std::collections::HashSet = params.iter().map(|p| p.id).collect(); // dayjs (issue: format() returned `292278994-08`): local IDs are diff --git a/crates/perry-hir/src/lower/fn_ctor_env.rs b/crates/perry-hir/src/lower/fn_ctor_env.rs index 48e7bbb0dc..40157159f5 100644 --- a/crates/perry-hir/src/lower/fn_ctor_env.rs +++ b/crates/perry-hir/src/lower/fn_ctor_env.rs @@ -214,10 +214,10 @@ pub(crate) fn build_fn_ctor_env(module: &ast::Module) -> FnCtorEnv { let mut decls: HashMap)> = HashMap::new(); let mut writes: HashMap = HashMap::new(); - let empty_shadow = Shadow::new(); + let mut empty_shadow = Shadow::new(); for item in &module.body { if let ast::ModuleItem::Stmt(stmt) = item { - scan_stmt(stmt, &mut decls, &mut writes, &empty_shadow); + scan_stmt(stmt, &mut decls, &mut writes, &mut empty_shadow); } } @@ -783,7 +783,7 @@ fn collect_fn_scope_names(stmts: &[ast::Stmt], out: &mut Shadow) { /// Treat every binding identifier in a pattern as a write (catch clauses, /// destructuring) — it shadows or mutates the name. -fn record_pat_bindings(pat: &ast::Pat, writes: &mut HashMap, shadow: &Shadow) { +fn record_pat_bindings(pat: &ast::Pat, writes: &mut HashMap, shadow: &mut Shadow) { match pat { ast::Pat::Ident(b) => record_write(&b.id.sym, writes, shadow), ast::Pat::Array(arr) => { @@ -820,7 +820,7 @@ fn scan_stmt( stmt: &ast::Stmt, decls: &mut HashMap)>, writes: &mut HashMap, - shadow: &Shadow, + shadow: &mut Shadow, ) { match stmt { ast::Stmt::Decl(ast::Decl::Var(var)) => { @@ -939,7 +939,11 @@ fn scan_stmt( } } -fn scan_for_head_writes(head: &ast::ForHead, writes: &mut HashMap, shadow: &Shadow) { +fn scan_for_head_writes( + head: &ast::ForHead, + writes: &mut HashMap, + shadow: &mut Shadow, +) { match head { ast::ForHead::VarDecl(v) => { for d in &v.decls { @@ -961,7 +965,7 @@ fn scan_for_head_writes(head: &ast::ForHead, writes: &mut HashMap fn scan_assign_target_writes( target: &ast::AssignTarget, writes: &mut HashMap, - shadow: &Shadow, + shadow: &mut Shadow, ) { match target { ast::AssignTarget::Simple(simple) => match simple { @@ -999,27 +1003,52 @@ fn scan_assign_target_writes( /// Walk a nested function for writes to NON-shadowed (module-level) names. /// The function's params and its own hoisted declarations extend the shadow. +/// +/// The shadow is threaded as a single shared `&mut Shadow` that we push the +/// function's own names onto and pop afterwards, instead of `clone()`ing the +/// whole enclosing shadow per nested function. The old clone made scanning a +/// scope of N sibling nested functions O(N²) (each clone copies the ~N +/// enclosing names) — pathological for modules/wrapper-IIFEs that declare many +/// sibling closures (the same class of perf bug as the capture-set rebuild). +/// To restore the shadow exactly, we only remove the names this frame newly +/// inserted (a name already shadowed by an outer scope must stay shadowed). fn scan_fn_body_writes( params: &[&ast::Pat], stmts: &[ast::Stmt], writes: &mut HashMap, - outer_shadow: &Shadow, + shadow: &mut Shadow, ) { - let mut shadow = outer_shadow.clone(); + // Gather the names this function frame introduces (params + hoisted + // declarations) without disturbing the shared shadow. + let mut frame_names = Shadow::new(); for p in params { - collect_pat_names(p, &mut shadow); + collect_pat_names(p, &mut frame_names); + } + collect_fn_scope_names(stmts, &mut frame_names); + + // Insert only the names not already shadowed, remembering them so we can + // pop exactly this frame's additions and leave outer shadows intact. + let mut added: Vec = Vec::new(); + for name in frame_names { + if shadow.insert(name.clone()) { + added.push(name); + } } - collect_fn_scope_names(stmts, &mut shadow); + let mut nested_decls: HashMap)> = HashMap::new(); for s in stmts { - scan_stmt(s, &mut nested_decls, writes, &shadow); + scan_stmt(s, &mut nested_decls, writes, shadow); + } + + for name in added { + shadow.remove(&name); } } fn scan_function_writes( function: &ast::Function, writes: &mut HashMap, - shadow: &Shadow, + shadow: &mut Shadow, ) { let params: Vec<&ast::Pat> = function.params.iter().map(|p| &p.pat).collect(); let stmts: &[ast::Stmt] = function @@ -1030,7 +1059,7 @@ fn scan_function_writes( scan_fn_body_writes(¶ms, stmts, writes, shadow); } -fn scan_class_writes(class: &ast::Class, writes: &mut HashMap, shadow: &Shadow) { +fn scan_class_writes(class: &ast::Class, writes: &mut HashMap, shadow: &mut Shadow) { if let Some(sup) = &class.super_class { scan_expr_writes(sup, writes, shadow); } @@ -1069,7 +1098,7 @@ fn scan_class_writes(class: &ast::Class, writes: &mut HashMap, sh } } -fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shadow: &Shadow) { +fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shadow: &mut Shadow) { match expr { ast::Expr::Assign(a) => { scan_assign_target_writes(&a.left, writes, shadow); @@ -1174,11 +1203,23 @@ fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shado scan_fn_body_writes(¶ms, &b.stmts, writes, shadow); } ast::BlockStmtOrExpr::Expr(e) => { - let mut inner = shadow.clone(); + // Arrow expression body: push the params, scan, then pop — + // mirrors `scan_fn_body_writes` so we don't clone the whole + // enclosing shadow per arrow. + let mut frame_names = Shadow::new(); for p in ¶ms { - collect_pat_names(p, &mut inner); + collect_pat_names(p, &mut frame_names); + } + let mut added: Vec = Vec::new(); + for name in frame_names { + if shadow.insert(name.clone()) { + added.push(name); + } + } + scan_expr_writes(e, writes, shadow); + for name in added { + shadow.remove(&name); } - scan_expr_writes(e, writes, &inner); } } } diff --git a/crates/perry-hir/src/lower/locals.rs b/crates/perry-hir/src/lower/locals.rs index 1912424f4c..7e3a150d67 100644 --- a/crates/perry-hir/src/lower/locals.rs +++ b/crates/perry-hir/src/lower/locals.rs @@ -26,7 +26,7 @@ use std::ops::{Deref, DerefMut}; use perry_types::{LocalId, Type}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// Ordered stack of scope-local bindings with a name→positions index. #[derive(Debug, Clone, Default)] @@ -36,6 +36,17 @@ pub(crate) struct Locals { /// name -> ascending positions into `entries`. The last entry of each /// list is the innermost (most-recent) binding for that name. index: HashMap>, + /// Set of the `LocalId`s currently present in `entries`. Maintained in + /// lockstep with `entries` by every mutating method, so closure-capture + /// analysis can do O(1) "is this id an enclosing local?" membership tests + /// against the *current* scope without rebuilding a `HashSet` from the + /// `entries` slice on every closure. Each binding gets a fresh monotonic + /// `LocalId` (`fresh_local`), so ids are unique within `entries` and a + /// plain set (no refcount) stays in sync. See `compute_closure_captures` + /// and `nested_fn_decl` — both formerly rebuilt this set per closure, + /// making capture analysis O(scope) per closure = O(n²) over a scope of + /// n sibling closures. + id_set: HashSet, } impl Locals { @@ -47,9 +58,17 @@ impl Locals { pub(crate) fn push(&mut self, entry: (String, LocalId, Type)) { let pos = self.entries.len(); self.index.entry(entry.0.clone()).or_default().push(pos); + self.id_set.insert(entry.1); self.entries.push(entry); } + /// The set of `LocalId`s currently in scope. O(1). Used by closure-capture + /// analysis as the "enclosing locals" membership view, replacing a + /// per-closure rebuild from the `entries` slice (#5267 follow-up). + pub(crate) fn id_set(&self) -> &HashSet { + &self.id_set + } + /// Position of the innermost binding named `name`, if any. O(1). /// Equivalent to the old `iter().rposition(|(n, ..)| n == name)`. pub(crate) fn lookup_index(&self, name: &str) -> Option { @@ -132,6 +151,9 @@ impl Locals { self.index.remove(name); } } + for (_, id, _) in &drained { + self.id_set.remove(id); + } drained } @@ -151,11 +173,13 @@ impl Locals { removed } - /// Rebuild the whole name→positions index from `entries`. + /// Rebuild the whole name→positions index and the id set from `entries`. fn reindex(&mut self) { self.index.clear(); - for (pos, (name, _, _)) in self.entries.iter().enumerate() { + self.id_set.clear(); + for (pos, (name, id, _)) in self.entries.iter().enumerate() { self.index.entry(name.clone()).or_default().push(pos); + self.id_set.insert(*id); } } } diff --git a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs index fe500bafbd..ccaeabbee2 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs @@ -38,13 +38,6 @@ pub(super) fn lower_nested_fn_decl( let scope_mark = ctx.enter_scope(); - // Track outer locals for capture detection - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); - // Lower parameters. Skip the TypeScript `this:` annotation — // it has no runtime existence (see the sibling site above for // the full rationale). @@ -170,8 +163,13 @@ pub(super) fn lower_nested_fn_decl( collect_local_refs_stmt(stmt, &mut all_refs, &mut visited_closures); } - let outer_local_ids: std::collections::HashSet = - outer_locals.iter().map(|(_, id)| *id).collect(); + // The function's own scope has been popped (`exit_scope` above), so the + // live `ctx.locals.id_set()` is exactly the enclosing scope's locals — the + // membership view capture detection needs. Previously this was rebuilt into + // a fresh `HashSet` from a per-closure cloned snapshot of `ctx.locals`, + // which made capture analysis O(scope) per nested function = O(n²) over a + // scope of n sibling functions (the perf bug behind this change). + let outer_local_ids = ctx.locals.id_set(); let param_ids: std::collections::HashSet = params.iter().map(|p| p.id).collect(); // dayjs (issue: format() returned `292278994-08`): local From 5baf8794621af9a966c01267767485a486fd1d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 18:19:44 +0200 Subject: [PATCH 02/17] =?UTF-8?q?perf(hir):=20O(1)=20registry=20lookups=20?= =?UTF-8?q?=E2=80=94=20index=20native=20instances/modules/statics=20by=20n?= =?UTF-8?q?ame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several per-call/per-member HIR resolution helpers did linear scans over `Vec` registries. For a program with K classes / native bindings and M call+member expressions, every lookup walked the whole registry — including the common miss case (receiver not registered) which scanned to the end and returned `None`. That is O(M*K), quadratic on large bundles, stalling check-lower. `lookup_class` was already indexed (classes_index, #5267). This indexes the remaining Vec-scanned lookups, mirroring the proven imported_functions_index pattern, while preserving identical Option/tuple results: - native_instances (scope-stack-like: pushed on scope entry, truncated on exit): name -> Vec shadow stack, innermost (last) on top. lookup reads the top index (== old `.rev().find()` last-match-wins). New `truncate_native_instances(mark)` pops indices >= mark off each name's stack (and the two prior direct `.truncate()` sites now call it), so an inner binding stops shadowing the moment its scope pops — same shadowing as before. - module_native_instances (module-level, push-only): name -> usize, overwritten on each push (last-match-wins, matching the reverse-scan fallback arm). - func_return_native_instances + native_modules + class_statics (push-only): name -> usize keeping the FIRST entry (`or_insert`), matching the old forward `.iter().find()` first-match-wins. has_static_method/has_static_field and lookup_native_module/lookup_func_return_native_instance now O(1). Push sites for module_native_instances / func_return_native_instances routed through new register helpers so the index stays in sync. Micro-bench (20000 x3 miss lookups vs K-sized registry, release): K=2000 baseline 82ms -> fixed 0.53ms K=8000 baseline 335ms -> fixed 0.51ms K=16000 baseline 1033ms-> fixed 0.54ms (~1900x at K=16000; flat in K) Adds unit tests for native-instance shadowing+truncation and module-level last-wins, plus an #[ignore] perf gate. Builds on the closure-capture perf fix. --- crates/perry-hir/src/lower/context.rs | 123 ++++++++++++++---- crates/perry-hir/src/lower/expr_assign.rs | 6 +- crates/perry-hir/src/lower/expr_call/mod.rs | 2 +- .../perry-hir/src/lower/lowering_context.rs | 26 ++++ crates/perry-hir/src/lower/module_decl.rs | 16 +-- crates/perry-hir/src/lower/stmt.rs | 8 +- crates/perry-hir/src/lower/tests.rs | 99 ++++++++++++++ crates/perry-hir/src/lower_decl/body_stmt.rs | 2 +- crates/perry-hir/src/lower_decl/fn_decl.rs | 4 +- 9 files changed, 241 insertions(+), 45 deletions(-) diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 83bab8f500..857ce4b7cb 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -116,6 +116,11 @@ impl LoweringContext { classes_index: HashMap::new(), imported_functions_index: HashMap::new(), builtin_module_aliases_index: HashMap::new(), + native_instances_index: HashMap::new(), + module_native_instances_index: HashMap::new(), + func_return_native_instances_index: HashMap::new(), + native_modules_index: HashMap::new(), + class_statics_index: HashMap::new(), weakref_locals: HashSet::new(), finreg_locals: HashSet::new(), weakmap_locals: HashSet::new(), @@ -602,23 +607,24 @@ impl LoweringContext { static_fields: Vec, static_methods: Vec, ) { + // Forward-scan (first-match-wins): index keeps the FIRST entry per name. + let idx = self.class_statics.len(); + self.class_statics_index.entry(class_name.clone()).or_insert(idx); self.class_statics .push((class_name, static_fields, static_methods)); } pub(crate) fn has_static_field(&self, class_name: &str, field_name: &str) -> bool { - self.class_statics - .iter() - .find(|(cn, _, _)| cn == class_name) - .map(|(_, fields, _)| fields.contains(&field_name.to_string())) + self.class_statics_index + .get(class_name) + .map(|&idx| self.class_statics[idx].1.iter().any(|f| f == field_name)) .unwrap_or(false) } pub(crate) fn has_static_method(&self, class_name: &str, method_name: &str) -> bool { - self.class_statics - .iter() - .find(|(cn, _, _)| cn == class_name) - .map(|(_, _, methods)| methods.contains(&method_name.to_string())) + self.class_statics_index + .get(class_name) + .map(|&idx| self.class_statics[idx].2.iter().any(|m| m == method_name)) .unwrap_or(false) } @@ -1022,15 +1028,19 @@ impl LoweringContext { module_name: String, method_name: Option, ) { + // Forward-scan (first-match-wins) semantics: only record the FIRST + // index for a name so lookups match the old `.iter().find()`. + let idx = self.native_modules.len(); + self.native_modules_index.entry(local_name.clone()).or_insert(idx); self.native_modules .push((local_name, module_name, method_name)); } pub(crate) fn lookup_native_module(&self, name: &str) -> Option<(&str, Option<&str>)> { - self.native_modules - .iter() - .find(|(n, _, _)| n == name) - .map(|(_, m, method)| (m.as_str(), method.as_ref().map(|s| s.as_str()))) + self.native_modules_index.get(name).map(|&idx| { + let (_, m, method) = &self.native_modules[idx]; + (m.as_str(), method.as_ref().map(|s| s.as_str())) + }) } pub(crate) fn register_builtin_module_alias( @@ -1085,10 +1095,39 @@ impl LoweringContext { if is_compile_package_override(&module_name) { return; } + // Push the new index onto this name's shadow stack (innermost last). + let idx = self.native_instances.len(); + self.native_instances_index + .entry(local_name.clone()) + .or_default() + .push(idx); self.native_instances .push((local_name, module_name, class_name)); } + /// Truncate `native_instances` back to `mark`, keeping the + /// `native_instances_index` shadow stacks in sync: every recorded index + /// `>= mark` is popped (these belong to bindings whose scope is exiting), + /// re-exposing any earlier (outer-scope) binding of the same name. Empty + /// stacks are removed to keep the map small. Use this everywhere + /// `native_instances.truncate(..)` was previously called directly. + pub(crate) fn truncate_native_instances(&mut self, mark: usize) { + if self.native_instances.len() <= mark { + return; + } + self.native_instances.truncate(mark); + // Drop indices >= mark from each name's shadow stack, re-exposing any + // earlier (outer-scope) binding. The map is keyed by distinct + // native-instance names (bounded, not proportional to class count), so + // this stays cheap. + self.native_instances_index.retain(|_, stack| { + while stack.last().is_some_and(|&i| i >= mark) { + stack.pop(); + } + !stack.is_empty() + }); + } + /// #1483: resolve a parameter's declared type name to a perry/ui widget /// class that uses handle-based instance dispatch (Canvas, State, ...). /// Returns the canonical widget name (e.g. "Canvas") when `type_name` @@ -1129,10 +1168,17 @@ impl LoweringContext { // inner `res` always resolved to the outer `("http", // "ServerResponse")` tag and `res.on('data')` misrouted // through ServerResponse dispatch instead of IncomingMessage.) - self.native_instances - .iter() - .rev() - .find(|(n, _, _)| n == name) + // + // Indexed (#5271): `native_instances_index[name]` is the shadow stack + // of indices for this name, innermost (last) on top — so the top index + // is exactly the entry the old `.rev().find()` would have selected. + // The `exposes_plain_object_fields` filter is then applied to THAT + // entry only (matching `.find().filter()`, which never falls through to + // an earlier match when the top one is filtered out). + self.native_instances_index + .get(name) + .and_then(|stack| stack.last()) + .map(|&idx| &self.native_instances[idx]) // `node:repl` constructors allocate real heap objects/errors with // bound methods; routing them through handle-dispatch native // getters turns ordinary fields like `Recoverable.err` into @@ -1141,11 +1187,11 @@ impl LoweringContext { .map(|(_, module, class)| (module.as_str(), class.as_str())) .or_else(|| { // Check module-level instances (survive scope exits). - // Same last-match-wins rule for consistency. - self.module_native_instances - .iter() - .rev() - .find(|(n, _, _)| n == name) + // Same last-match-wins rule for consistency — the index stores + // the LAST pushed entry per name. + self.module_native_instances_index + .get(name) + .map(|&idx| &self.module_native_instances[idx]) .filter(|(_, module, class)| !exposes_plain_object_fields(module, class)) .map(|(_, module, class)| (module.as_str(), class.as_str())) }) @@ -1155,10 +1201,35 @@ impl LoweringContext { &self, func_name: &str, ) -> Option<(&str, &str)> { - self.func_return_native_instances - .iter() - .find(|(n, _, _)| n == func_name) - .map(|(_, module, class)| (module.as_str(), class.as_str())) + // Forward-scan (first-match-wins): index keeps the FIRST pushed entry. + self.func_return_native_instances_index + .get(func_name) + .map(|&idx| { + let (_, module, class) = &self.func_return_native_instances[idx]; + (module.as_str(), class.as_str()) + }) + } + + /// Push a function-return native instance (push-only, never truncated) and + /// update its perf index. `lookup_func_return_native_instance` scanned + /// FORWARD (first-match-wins), so the index keeps the FIRST pushed entry. + pub(crate) fn push_func_return_native_instance(&mut self, entry: (String, String, String)) { + let idx = self.func_return_native_instances.len(); + self.func_return_native_instances_index + .entry(entry.0.clone()) + .or_insert(idx); + self.func_return_native_instances.push(entry); + } + + /// Push a module-level native instance (module-scoped, never truncated) + /// and update its perf index. `lookup_native_instance`'s fallback arm + /// scans these in reverse (last-match-wins), so the index stores the LAST + /// pushed entry per name (overwrite). + pub(crate) fn push_module_native_instance(&mut self, entry: (String, String, String)) { + let idx = self.module_native_instances.len(); + self.module_native_instances_index + .insert(entry.0.clone(), idx); + self.module_native_instances.push(entry); } } @@ -1241,7 +1312,7 @@ impl LoweringContext { } self.locals.extend(kept); } - self.native_instances.truncate(mark.1); + self.truncate_native_instances(mark.1); // Remove index entries for functions being truncated, then restore any // earlier entries that were shadowed by the removed ones. for i in mark.2..self.functions.len() { diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index 40ddb11537..64ee51a3a0 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -192,7 +192,7 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) _ => Some("Instance"), }; if let Some(class_name) = class_name { - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( var_name.clone(), module_name.to_string(), class_name.to_string(), @@ -217,7 +217,7 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) module_name.clone(), class_name_str.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( var_name.clone(), module_name, class_name_str.to_string(), @@ -230,7 +230,7 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) if let ast::Expr::Ident(rhs_ident) = inner_rhs { let rhs_name = rhs_ident.sym.as_ref(); if let Some((module, class)) = ctx.lookup_native_instance(rhs_name) { - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( var_name, module.to_string(), class.to_string(), diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 8a4f73ef30..1b338d9a3d 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -143,7 +143,7 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res // are likewise out of scope once we unwind past their owning // call). `truncate` is a no-op when nothing was added. if ctx.native_instances.len() > ni_mark { - ctx.native_instances.truncate(ni_mark); + ctx.truncate_native_instances(ni_mark); } result } diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 94fefa452a..9c8cf36792 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -367,6 +367,32 @@ pub struct LoweringContext { pub(crate) imported_functions_index: HashMap, /// Shadow index: local alias name -> index in `builtin_module_aliases` Vec pub(crate) builtin_module_aliases_index: HashMap, + /// Perf index for `native_instances` (which is scope-stack-like: pushed on + /// scope entry, truncated on scope exit). Maps name -> STACK of indices into + /// the `native_instances` Vec, innermost (last) on top. `lookup_native_instance` + /// reads the top index for O(1) last-match-wins resolution (mirrors the old + /// reverse scan's shadowing); on `truncate(mark)` every index >= mark is + /// popped off each name's stack (and empty stacks removed), so an inner + /// binding stops shadowing the moment its scope pops. See + /// `register_native_instance` / `truncate_native_instances`. + pub(crate) native_instances_index: HashMap>, + /// Perf index for `module_native_instances` (module-level, push-only, never + /// truncated). Scanned in reverse (last-match-wins) by + /// `lookup_native_instance`'s fallback arm, so the index stores the LAST + /// pushed index per name (overwritten on every push). O(1) lookup. + pub(crate) module_native_instances_index: HashMap, + /// Perf index for `func_return_native_instances` (push-only, never + /// truncated). The old lookup scanned FORWARD (first-match-wins), so the + /// index keeps the FIRST pushed index per name (`entry().or_insert`). + pub(crate) func_return_native_instances_index: HashMap, + /// Perf index for `native_modules` (push-only, never truncated). The old + /// `lookup_native_module` scanned FORWARD (first-match-wins), so the index + /// keeps the FIRST pushed index per name (`entry().or_insert`). + pub(crate) native_modules_index: HashMap, + /// Perf index for `class_statics` (push-only, never truncated). The old + /// `has_static_method`/`has_static_field` scanned FORWARD (first-match-wins), + /// so the index keeps the FIRST pushed index per class name. + pub(crate) class_statics_index: HashMap, /// Local names bound to a `path` sub-namespace (`const w = path.win32`). /// Maps the local name -> (root identifier name, sub "win32"|"posix"). /// Resolution of the root identifier to the `path` module is deferred to diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 186e829cc9..d9db3a1c7e 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -486,7 +486,7 @@ pub(crate) fn lower_module_decl( if let Some((module, class)) = native_instance_from_return_type(&func.return_type) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func_name.clone(), module.to_string(), class.to_string(), @@ -784,7 +784,7 @@ pub(crate) fn lower_module_decl( // Without this, pool = mysql.createPool() at module top level loses // its native tracking when function scopes are entered/exited, // causing pool.query() inside functions to miss the Pool dispatch. - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), class_module, class_name.to_string(), @@ -868,7 +868,7 @@ pub(crate) fn lower_module_decl( module.clone(), class_name.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module, class_name.to_string(), @@ -969,7 +969,7 @@ pub(crate) fn lower_module_decl( module_name.clone(), class_name_str.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module_name, class_name_str.to_string(), @@ -1011,7 +1011,7 @@ pub(crate) fn lower_module_decl( module_name.clone(), class_name_str.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module_name, class_name_str.to_string(), @@ -1141,7 +1141,7 @@ pub(crate) fn lower_module_decl( } }; if let Some((module, class)) = module_info { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( name.clone(), module.to_string(), class.to_string(), @@ -1699,7 +1699,7 @@ pub(crate) fn lower_module_decl( if let Some((mod_name, class)) = native_instance_from_return_type(&func.return_type) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func_name.clone(), mod_name.to_string(), class.to_string(), @@ -2234,7 +2234,7 @@ pub(crate) fn lower_namespace_as_class( if let Some((module, class)) = native_instance_from_return_type(&func.return_type) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func.name.clone(), module.to_string(), class.to_string(), diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 49ff32bf6a..84f3ee4511 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -327,7 +327,7 @@ pub(crate) fn lower_stmt( if let Some((module, class)) = native_instance_from_return_type(&func.return_type) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func.name.clone(), module.to_string(), class.to_string(), @@ -929,7 +929,7 @@ pub(crate) fn lower_stmt( module_owned.clone(), cn.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module_owned, cn.to_string(), @@ -946,7 +946,7 @@ pub(crate) fn lower_stmt( module_owned.clone(), cn.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module_owned, cn.to_string(), @@ -990,7 +990,7 @@ pub(crate) fn lower_stmt( "net".to_string(), "Server".to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), "net".to_string(), "Server".to_string(), diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 6df1695650..79a2d982fd 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -312,6 +312,105 @@ fn test_lower_rejects_deep_logical_chain() { assert_too_deep(format!("var a = 0;\nvar x = {};\n", chain.join("||"))); } +/// #5271: the perf index over `native_instances` must reproduce the old +/// reverse-scan semantics exactly — innermost (last-registered) binding wins, +/// and `truncate_native_instances` re-exposes the outer binding when the inner +/// scope pops. Mirrors the `lookup_native_instance` last-match-wins rule. +#[test] +fn test_native_instance_index_shadowing_and_truncation() { + let mut ctx = make_ctx(); + // Outer binding `e` -> events/EventEmitter. + ctx.register_native_instance( + "e".to_string(), + "events".to_string(), + "EventEmitter".to_string(), + ); + assert_eq!( + ctx.lookup_native_instance("e"), + Some(("events", "EventEmitter")) + ); + + // Enter an inner scope: shadow `e` with a different native type. + let mark = ctx.native_instances.len(); + ctx.register_native_instance("e".to_string(), "stream".to_string(), "Readable".to_string()); + // Inner (last) binding wins. + assert_eq!(ctx.lookup_native_instance("e"), Some(("stream", "Readable"))); + + // Pop the inner scope: the outer binding must be restored. + ctx.truncate_native_instances(mark); + assert_eq!( + ctx.lookup_native_instance("e"), + Some(("events", "EventEmitter")) + ); + + // Pop the outer binding too: no entry remains. + ctx.truncate_native_instances(0); + assert!(ctx.lookup_native_instance("e").is_none()); +} + +/// #5271: module-level native instances (never truncated) keep last-match-wins +/// via the overwrite index, matching the old reverse scan of the fallback arm. +#[test] +fn test_module_native_instance_index_last_wins() { + let mut ctx = make_ctx(); + ctx.push_module_native_instance(( + "db".to_string(), + "mongodb".to_string(), + "MongoClient".to_string(), + )); + assert_eq!( + ctx.lookup_native_instance("db"), + Some(("mongodb", "MongoClient")) + ); + // A later registration of the same name shadows the earlier one. + ctx.push_module_native_instance(( + "db".to_string(), + "mysql2/promise".to_string(), + "Pool".to_string(), + )); + assert_eq!( + ctx.lookup_native_instance("db"), + Some(("mysql2/promise", "Pool")) + ); +} + +/// #5271 perf gate (run with `--release --ignored`): time M lookups against a +/// K-sized registry to show indexed lookups are ~flat in K (O(1)) rather than +/// O(K) per call. Prints timings; not asserted (machine-dependent) but the +/// flatness across K is the observable signal. Covers the registries whose +/// linear scans this change indexed. +#[test] +#[ignore] +fn perf_registry_lookup_is_flat_in_k() { + use std::time::Instant; + const M: usize = 20_000; + for k in [0usize, 2_000, 8_000, 16_000] { + let mut ctx = make_ctx(); + for i in 0..k { + ctx.register_class_statics( + format!("K{i}"), + vec![format!("f{i}")], + vec![format!("s{i}")], + ); + ctx.register_native_instance(format!("ni{i}"), "events".into(), "EventEmitter".into()); + ctx.register_native_module(format!("nm{i}"), "fs".into(), None); + } + // The hot case the bug targets: the receiver is NOT in the registry, so + // the old reverse/forward scan walked the whole Vec and returned None. + let t = Instant::now(); + let mut acc = 0u64; + for _ in 0..M { + acc += ctx.has_static_method("Missing", "s") as u64; + acc += ctx.lookup_native_instance("missing").is_some() as u64; + acc += ctx.lookup_native_module("missing").is_some() as u64; + } + eprintln!( + "K={k:<6} {M} x3 lookups: {:?} (acc={acc})", + t.elapsed() + ); + } +} + /// A chain comfortably under the ceiling still lowers cleanly — the guard /// must not reject ordinary (if large) expressions. #[test] diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 5993e484ce..74d8cd756b 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1848,7 +1848,7 @@ pub fn find_native_return_in_stmts( let var = ident.sym.as_ref(); for i in ni_start..ctx.native_instances.len() { if ctx.native_instances[i].0 == var { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func_name.to_string(), ctx.native_instances[i].1.clone(), ctx.native_instances[i].2.clone(), diff --git a/crates/perry-hir/src/lower_decl/fn_decl.rs b/crates/perry-hir/src/lower_decl/fn_decl.rs index 07d7e35a1d..bca1217426 100644 --- a/crates/perry-hir/src/lower_decl/fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/fn_decl.rs @@ -227,7 +227,7 @@ pub fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> Result let module_alias = &type_name[..dot_pos]; let class_name = &type_name[dot_pos + 1..]; if let Some((module_name, _)) = ctx.lookup_native_module(module_alias) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( name.clone(), module_name.to_string(), class_name.to_string(), @@ -245,7 +245,7 @@ pub fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> Result _ => None, }; if let Some((module, class)) = module_info { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( name.clone(), module.to_string(), class.to_string(), From 11731e549b8eb3a5f7ef7e47888a58b439e18ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 20:34:13 +0200 Subject: [PATCH 03/17] perf(hir): fix exponential re-lowering of native-fluent method chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 13 MB minified ESM bundle (a commander-based CLI) made `perry check` stall in the HIR `check-lower` stage forever (>1500 s, never finishing). Instrumenting `lower_expr` (env-gated `PERRY_TRACE_RELOWER`, counting lowerings per source span) showed a single ~360-byte commander builder chain — `K.name(..).description(..).argument(..).helpOption(..) .option(..).addOption(..)…` — whose receiver subtrees were lowered EXPONENTIALLY: span counts of 37M / 18.5M / 9.2M / 4.6M / 2.3M, halving once per nesting level (a clean 2^depth signature). Root cause: the chained-native-method dispatch helper `try_static_method_and_instance` (expr_call/static_and_instance.rs). `may_lower_to_native_method_call` over-approximates to `true` whenever the chain root is a native instance/module ident (here `K`, tagged commander via `new Command()`), so the helper SPECULATIVELY lowers the whole receiver prefix to inspect whether it produced a `NativeMethodCall` of a recognized fluent module. When the inner call instead lowers to a generic `Call` (or the outer method isn't one of the recognized fluent methods — `hook`/`helpOption`/`addOption`…), every fluent arm misses, the lowered receiver is discarded, and the helper returns `Err(args)`. The `lower_call_inner` fall-through tail then RE-lowers the same member callee (and thus the whole prefix) via `lower_member_inner`. Two full recursive descents into the prefix per chain level ⇒ 2^depth work. Fix: lower each receiver exactly once. When the helper lowers `member.obj` and no fluent arm consumes it, stash it in `LoweringContext::prelowered_member_receiver` keyed by the receiver's source span; `lower_member_inner` (the tail's receiver-lowering site) takes it back when re-lowering the same span instead of redoing the work. The memo is single-shot and span-keyed, any member lowering clears a stale entry, and `lower_call_inner` resets it as a safety net — so it can never leak onto a different receiver. Reuse is semantics-preserving: lowering a receiver is idempotent in the value it produces, and the fluent-success arms already reuse that very `object_expr`. Results: - Real bundle: `perry check /tmp/cli.ts` >1500 s (never finishes) → 11.9 s, prints "All checks passed! - 2 file(s) checked". - Minimal synthetic (commander chain mixing recognized/unrecognized methods), before: N=12 0.07s, N=14 0.52s, N=16 4.0s, N=18 16.3s, N≥20 >30 s timeout (exponential). After: N=20 0.01s, N=500 0.5s — the exponential re-lowering is gone (no span lowered more than ~once; `PERRY_TRACE_RELOWER` never trips its 5M-call dump even at N=2000). - `cargo test -p perry-hir --tests`: 323 passed, 0 failures (excluding the 4 pre-existing machine-specific debug-build stack-overflow tests test_lower_rejects_deep_* / nested_object_literal_lowers_in_linear_time, confirmed identical on HEAD). The `PERRY_TRACE_RELOWER` counter is left in place, fully env-gated and zero-cost when unset, as a standing diagnostic for future lowering perf work. --- crates/perry-hir/src/lower/context.rs | 1 + crates/perry-hir/src/lower/expr_call/mod.rs | 9 +++ .../lower/expr_call/static_and_instance.rs | 29 ++++++++- crates/perry-hir/src/lower/expr_member.rs | 13 +++- crates/perry-hir/src/lower/lower_expr.rs | 61 +++++++++++++++++++ .../perry-hir/src/lower/lowering_context.rs | 15 +++++ 6 files changed, 126 insertions(+), 2 deletions(-) diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 857ce4b7cb..4b484d14a8 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -161,6 +161,7 @@ impl LoweringContext { optional_require_try_depth: 0, fn_ctor_env: super::fn_ctor_env::FnCtorEnv::default(), expr_lower_depth: 0, + prelowered_member_receiver: None, } } diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 1b338d9a3d..856986aa4d 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -149,6 +149,15 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res } fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result { + // Safety net for the receiver-lowering memo (see + // `LoweringContext::prelowered_member_receiver`): the memo is set by + // `try_static_method_and_instance` only to be consumed by the immediately + // following fall-through tail's `lower_member_inner`. It is single-shot and + // span-keyed, but in case a code path sets it without the tail consuming it, + // drop any stale entry here so it can never leak across calls. This runs + // before the callee tail (which lowers a Member, not a Call), so it never + // clobbers an in-flight memo for the call currently being lowered. + ctx.prelowered_member_receiver = None; // Check if any argument has spread let has_spread = call.args.iter().any(|arg| arg.spread.is_some()); diff --git a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs index 59541ea50f..62e9838806 100644 --- a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs @@ -4,6 +4,7 @@ use anyhow::{anyhow, Result}; use perry_types::{LocalId, Type}; +use swc_common::Spanned; use swc_ecma_ast as ast; use super::stream::is_stream_api_method; @@ -345,7 +346,27 @@ pub(super) fn try_static_method_and_instance( if !may_lower_to_native_method_call(ctx, &member.obj) { return Ok(Err(args)); } - // Lower the object expression first + // Lower the object expression once. + // + // Perf (O(n²)/exponential → O(n) on long native-fluent chains): this + // is a FULL recursive lowering of the entire receiver prefix, done + // speculatively to inspect whether the inner call lowered to a + // `NativeMethodCall` of a recognized fluent module. On a chain like + // `K.name(..).description(..).option(..)…` (commander / minified CLI + // builders) where `may_lower_to_native_method_call` over-approximates + // to `true` but the inner call actually lowers to a *generic* `Call`, + // every arm below misses, `object_expr` is discarded, and we return + // `Err(args)` — whereupon the `lower_call_inner` fall-through tail + // re-lowers the same member callee (and thus this whole prefix) + // again. Repeated per chain level that is exponential blowup. + // + // To make the tail reuse this lowering instead of redoing it, stash + // it keyed by the receiver's source span. `lower_member_inner` (the + // tail's receiver-lowering site) consumes it for the matching span. + // Reuse is sound: lowering a receiver is idempotent in the value it + // produces, and the fluent-success arms below already reuse this very + // `object_expr`. + let obj_span = member.obj.as_ref().span(); let object_expr = lower_expr(ctx, &member.obj)?; // Check if it's a NativeMethodCall for a fluent-API native module if let Expr::NativeMethodCall { @@ -488,6 +509,12 @@ pub(super) fn try_static_method_and_instance( })); } } + // No fluent arm matched: we are about to return `Err(args)` and the + // `lower_call_inner` fall-through tail will re-lower this same member + // callee. Hand it the receiver we just lowered so it doesn't repeat + // the (potentially whole-prefix) work. Keyed by span so the tail only + // reuses it for the exact receiver subtree. + ctx.prelowered_member_receiver = Some(((obj_span.lo.0, obj_span.hi.0), object_expr)); } } diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index b932d096e2..50bc1b02da 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -11,6 +11,7 @@ use anyhow::Result; use perry_types::Type; +use swc_common::Spanned; use swc_ecma_ast as ast; use crate::ir::Expr; @@ -1781,7 +1782,17 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re } } - let mut object_expr = lower_expr(ctx, &member.obj)?; + // Perf: reuse a receiver already lowered by `try_static_method_and_instance` + // (the chained-native-method dispatch helper) for THIS exact member callee, + // instead of re-lowering the whole prefix. See + // `LoweringContext::prelowered_member_receiver`. Match strictly by span and + // take it (single-shot) so a stale memo can never leak onto a different + // receiver. Any other consumer along the way invalidates it. + let obj_span = member.obj.as_ref().span(); + let mut object_expr = match ctx.prelowered_member_receiver.take() { + Some((key, lowered)) if key == (obj_span.lo.0, obj_span.hi.0) => lowered, + _ => lower_expr(ctx, &member.obj)?, + }; if let ast::MemberProp::Ident(prop_ident) = &member.prop { if let Some(value) = ws_ready_state_value(prop_ident.sym.as_ref()) { if is_ws_ready_state_receiver(ctx, member.obj.as_ref(), &object_expr) { diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index 5ae2de1ffe..2f6402c9d4 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -490,7 +490,68 @@ pub(crate) fn native_module_binding_value(ctx: &LoweringContext, name: &str) -> Expr::NativeModuleRef(module_name.to_string()) } +/// Re-lowering diagnostics, fully gated behind the `PERRY_TRACE_RELOWER` env +/// var (zero overhead unless set). Counts every `lower_expr` invocation keyed +/// by source span, so a span lowered far more than once flags redundant +/// re-lowering (the classic source of super-linear HIR-lowering blowup on +/// minified bundles). On every N-million calls — and so still on a kill — it +/// dumps the total/distinct counts and the top re-lowered spans to stderr. +/// Kept (env-gated) as a standing diagnostic for future lowering perf work. +pub(crate) mod relower_trace { + use std::cell::RefCell; + use std::collections::HashMap; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + + static ENABLED: AtomicBool = AtomicBool::new(false); + static INIT: AtomicBool = AtomicBool::new(false); + static TOTAL: AtomicU64 = AtomicU64::new(0); + + thread_local! { + static SPANS: RefCell> = RefCell::new(HashMap::new()); + } + + pub fn enabled() -> bool { + if !INIT.load(Ordering::Relaxed) { + let on = std::env::var("PERRY_TRACE_RELOWER").is_ok(); + ENABLED.store(on, Ordering::Relaxed); + INIT.store(true, Ordering::Relaxed); + } + ENABLED.load(Ordering::Relaxed) + } + + pub fn record(lo: u32, hi: u32) { + let n = TOTAL.fetch_add(1, Ordering::Relaxed) + 1; + SPANS.with(|m| { + *m.borrow_mut().entry((lo, hi)).or_insert(0) += 1; + }); + if n % 5_000_000 == 0 { + dump(&format!("periodic@{n}")); + } + } + + fn dump(tag: &str) { + SPANS.with(|m| { + let m = m.borrow(); + let total = TOTAL.load(Ordering::Relaxed); + let distinct = m.len(); + let mut v: Vec<_> = m.iter().map(|(k, c)| (*c, *k)).collect(); + v.sort_unstable_by(|a, b| b.0.cmp(&a.0)); + eprintln!( + "RELOWER[{tag}] total={total} distinct={distinct} ratio={:.2}", + total as f64 / distinct.max(1) as f64 + ); + for (c, (lo, hi)) in v.into_iter().take(20) { + eprintln!("RELOWER span {lo}..{hi} count={c}"); + } + }); + } +} + pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result { + if relower_trace::enabled() { + let sp = expr.span(); + relower_trace::record(sp.lo.0, sp.hi.0); + } // #5259: guard the recursive descent. Without this, a pathologically // nested expression (`1+1+…`, `o.a.a.…`, `a||a||…`) overflows the native // stack and SIGABRTs with no diagnostic. The depth counter turns that into diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 9c8cf36792..46e6eb8170 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -642,4 +642,19 @@ pub struct LoweringContext { /// pathologically-nested expression chain (bundler/minifier output like /// `1+1+…+1` or `o.a.a.…a`) overflow the native stack and SIGABRT. pub(crate) expr_lower_depth: u32, + /// Perf: a single-slot memo of an already-lowered member receiver, keyed by + /// its source span `(lo, hi)`. Set by the chained-native-method dispatch + /// helper (`try_static_method_and_instance`) just before it returns + /// `Err(args)` after lowering `member.obj` to inspect it; consumed once by + /// `lower_member_inner` when the `lower_call_inner` fall-through tail + /// re-lowers the same member callee. Without it, a long native-fluent + /// method chain (`K.name(..).description(..).option(..)…` — commander/minified + /// CLI builders) re-lowers the entire receiver prefix at every chain level + /// (the helper lowers it, finds the inner result is not a `NativeMethodCall`, + /// discards it, and the tail lowers it again — compounding to exponential + /// blowup). The memo lets the tail reuse the helper's lowering, so each + /// receiver subtree is lowered exactly once. Lowering a receiver is + /// idempotent w.r.t. the value produced (the fluent-success path already + /// reuses the same lowered receiver), so reusing it is semantics-preserving. + pub(crate) prelowered_member_receiver: Option<((u32, u32), Expr)>, } From 0ddb9793361582843b735b33a53bf0ad2e29fefc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 21:20:28 +0200 Subject: [PATCH 04/17] fix(codegen): argless builtins ignore extra args instead of bailing JS ignores surplus arguments to argless methods ("x".trim(1) is legal and returns the trimmed string). perry's codegen bail!'d with "takes no args, got N" for String.{toLowerCase,toUpperCase,trim,trimStart,trimEnd, isWellFormed,toWellFormed} and Array.{pop,shift}, rejecting valid JS. Drop the arg-count bail in all 5 sites; evaluate extra args for their side effects (ECMA-262 evaluates arguments before the call) then discard, matching the existing Annex B HTML-wrapper convention in the same file. Clears the first codegen wall on the claude-code cli.js bundle. Adds tests/argless_builtin_extra_args.rs covering "x".trim(1) and [1].pop(99). --- .../perry-codegen/src/lower_array_method.rs | 14 +- .../perry-codegen/src/lower_string_method.rs | 27 ++-- .../tests/argless_builtin_extra_args.rs | 129 ++++++++++++++++++ 3 files changed, 147 insertions(+), 23 deletions(-) create mode 100644 crates/perry-codegen/tests/argless_builtin_extra_args.rs diff --git a/crates/perry-codegen/src/lower_array_method.rs b/crates/perry-codegen/src/lower_array_method.rs index 5f5713f05b..19b3debbcd 100644 --- a/crates/perry-codegen/src/lower_array_method.rs +++ b/crates/perry-codegen/src/lower_array_method.rs @@ -29,8 +29,10 @@ pub(crate) fn lower_array_method( match property { "pop" => { - if !args.is_empty() { - bail!("perry-codegen: Array.pop takes no args, got {}", args.len()); + // No-arg method; JS ignores extras but evaluates them for side + // effects before the call. Evaluate then discard. + for extra in args.iter() { + let _ = lower_expr(ctx, extra)?; } let blk = ctx.block(); let recv_handle = unbox_to_i64(blk, &recv_box); @@ -723,11 +725,9 @@ pub(crate) fn lower_array_method( Ok(nanbox_pointer_inline(blk, &result)) } "shift" => { - if !args.is_empty() { - bail!( - "perry-codegen: Array.shift takes no args, got {}", - args.len() - ); + // No-arg method; extras are evaluated for side effects then ignored. + for extra in args.iter() { + let _ = lower_expr(ctx, extra)?; } let blk = ctx.block(); let recv_handle = unbox_to_i64(blk, &recv_box); diff --git a/crates/perry-codegen/src/lower_string_method.rs b/crates/perry-codegen/src/lower_string_method.rs index 8b6808a2bf..f68b10343d 100644 --- a/crates/perry-codegen/src/lower_string_method.rs +++ b/crates/perry-codegen/src/lower_string_method.rs @@ -310,12 +310,11 @@ pub(crate) fn lower_string_method( } // Unary string-returning methods (no args). "toLowerCase" | "toUpperCase" | "trim" | "trimStart" | "trimEnd" => { - if !args.is_empty() { - bail!( - "perry-codegen: String.{} takes no args, got {}", - property, - args.len() - ); + // These methods take no args; JS ignores any extras but still + // evaluates them for side effects (ECMA-262 argument evaluation + // precedes the call). Evaluate then discard. + for extra in args.iter() { + let _ = lower_expr(ctx, extra)?; } let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -900,11 +899,9 @@ pub(crate) fn lower_string_method( Ok(nanbox_pointer_inline(blk, &result)) } "isWellFormed" => { - if !args.is_empty() { - bail!( - "perry-codegen: String.isWellFormed takes no args, got {}", - args.len() - ); + // No-arg method; extras are evaluated for side effects then ignored. + for extra in args.iter() { + let _ = lower_expr(ctx, extra)?; } let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -912,11 +909,9 @@ pub(crate) fn lower_string_method( Ok(blk.call(DOUBLE, "js_string_is_well_formed", &[(I64, &recv_handle)])) } "toWellFormed" => { - if !args.is_empty() { - bail!( - "perry-codegen: String.toWellFormed takes no args, got {}", - args.len() - ); + // No-arg method; extras are evaluated for side effects then ignored. + for extra in args.iter() { + let _ = lower_expr(ctx, extra)?; } let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); diff --git a/crates/perry-codegen/tests/argless_builtin_extra_args.rs b/crates/perry-codegen/tests/argless_builtin_extra_args.rs new file mode 100644 index 0000000000..5b5c5cf45d --- /dev/null +++ b/crates/perry-codegen/tests/argless_builtin_extra_args.rs @@ -0,0 +1,129 @@ +//! Regression: argless builtin methods (`String.trim`, `String.toLowerCase`, +//! `Array.pop`, …) must accept and ignore extra arguments rather than bailing. +//! +//! JS ignores surplus args to argless methods (`" x ".trim(1)` is legal and +//! returns the trimmed string), so codegen must lower these without erroring. + +use perry_codegen::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::{Expr, Module, ModuleInitKind, Stmt}; + +fn empty_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: false, + non_entry_module_prefixes: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + namespace_reexport_named_imports: std::collections::HashSet::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: perry_codegen::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + } +} + +fn module_with_init(init: Vec) -> Module { + Module { + name: "argless_extra_args.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: Vec::new(), + init, + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +/// `" x ".trim(1)` — String.trim is argless but JS ignores the extra arg. +/// Codegen must lower this without bailing (`String.trim takes no args`). +#[test] +fn string_trim_with_extra_arg_compiles() { + let stmt = Stmt::Expr(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::String(" x ".to_string())), + property: "trim".to_string(), + }), + args: vec![Expr::Number(1.0)], + type_args: Vec::new(), + byte_offset: 0, + }); + let ir = compile_module(&module_with_init(vec![stmt]), empty_opts()) + .expect("\" x \".trim(1) must compile (extra arg ignored, not an error)"); + let ir = String::from_utf8(ir).unwrap(); + // The runtime trim helper must still be emitted — the arg is dropped, not + // routed into a different code path. + assert!( + ir.contains("js_string_trim"), + "expected trim lowering to emit js_string_trim" + ); +} + +/// `[1].pop(99)` — Array.pop is argless; the extra arg must be ignored. +#[test] +fn array_pop_with_extra_arg_compiles() { + let stmt = Stmt::Expr(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::Array(vec![Expr::Number(1.0)])), + property: "pop".to_string(), + }), + args: vec![Expr::Number(99.0)], + type_args: Vec::new(), + byte_offset: 0, + }); + compile_module(&module_with_init(vec![stmt]), empty_opts()) + .expect("[1].pop(99) must compile (extra arg ignored, not an error)"); +} From 1e41fe7ac73d2ff8ed9eca4f0603651f363ecc80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 22:26:04 +0200 Subject: [PATCH 05/17] fix(transform): async-generator catch with break/continue no longer drops dispatch loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user break/continue inside a try/catch within a loop body of an async function* compiled to a bare Stmt::Continue with no enclosing loop, crashing codegen with 'continue statement outside any loop' (e.g. claude-code cli.js, the query main loop: while(k){ try{...for await...}catch{...continue} }). Root cause: rewrite_break_continue_in_stmts lowers a user break/continue into [LocalSet(state, SENTINEL), Stmt::Continue], where the trailing Stmt::Continue re-enters the state-machine dispatch while(true) loop. For a real async function* (was_plain_async = false), the catch handler is inlined verbatim into the separate __async_throw closure via build_async_catch_route_body. That closure has NO dispatch loop, so: 1. the dispatch Stmt::Continue became a continue with no target, and 2. its CONTINUE_SENTINEL state number was never fixed up, because fix_break_continue_sentinels only walks the linearized states, not the extracted CatchRoute bodies. Fix: 1. linearize.rs: after linearizing a while/for body, also run fix_break_continue_sentinels on the CatchRoutes captured during that body (new fix_break_continue_sentinels_in_catches) so the resume state in an inlined catch points at the loop's real cond/update/after-loop state. 2. lower.rs: in build_async_catch_route_body (async path), convert the now-dangling dispatch Stmt::Continue/Break re-entry into a suspend return { value: undefined, done: false } — correct async-generator semantics: the next .next() dispatches at the resume state already set by the preceding LocalSet. Verified: minimal repro (async function* with while+try/catch+continue) and a continue-in-try-body loop both compile through codegen and run correctly (yields 1,3,5 skipping evens via continue). The claude-code cli.js bundle now compiles PAST this error. Note: a separate PRE-EXISTING limitation remains — synchronous/awaited throws inside try in an async generator are not caught at runtime (fails identically on HEAD without any loop/continue); out of scope here. --- .../src/generator/break_continue.rs | 25 +++++++++ .../src/generator/linearize.rs | 20 +++++++ crates/perry-transform/src/generator/lower.rs | 56 +++++++++++++++++++ crates/perry-transform/src/generator/mod.rs | 6 +- 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/crates/perry-transform/src/generator/break_continue.rs b/crates/perry-transform/src/generator/break_continue.rs index 1e7daab08d..dc05d40ba8 100644 --- a/crates/perry-transform/src/generator/break_continue.rs +++ b/crates/perry-transform/src/generator/break_continue.rs @@ -116,6 +116,31 @@ pub fn fix_break_continue_sentinels_in_stmts( } } +/// Fix BREAK/CONTINUE sentinels inside the bodies of `CatchRoute`s captured +/// while linearizing a loop body. The async-generator `.throw()` closure +/// inlines `route.body` verbatim (no dispatch loop), so a user `continue`/ +/// `break` inside such a catch was rewritten to +/// `[LocalSet(state, SENTINEL), Stmt::Continue]` but its sentinel never got +/// fixed (`fix_break_continue_sentinels` only walks the linearized `states`, +/// not the extracted catch routes). Apply the same loop targets to those +/// catch-route bodies so the resume state is correct (the dangling dispatch +/// `Stmt::Continue` is then neutralized by the async catch-route inliner). +pub fn fix_break_continue_sentinels_in_catches( + catches: &mut [CatchRoute], + state_id: LocalId, + break_target: u32, + continue_target: u32, +) { + for route in catches.iter_mut() { + fix_break_continue_sentinels_in_stmts( + &mut route.body, + state_id, + break_target, + continue_target, + ); + } +} + pub fn fix_break_continue_sentinels_in_stmt( stmt: &mut Stmt, state_id: LocalId, diff --git a/crates/perry-transform/src/generator/linearize.rs b/crates/perry-transform/src/generator/linearize.rs index 2a5d565d69..c2430b2cc2 100644 --- a/crates/perry-transform/src/generator/linearize.rs +++ b/crates/perry-transform/src/generator/linearize.rs @@ -513,6 +513,7 @@ pub fn linearize_body( // skipping the body tail without going through the update. let body_states_before = states.len(); 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); @@ -590,6 +591,15 @@ pub fn linearize_body( after_loop_state, update_state, ); + // Async-generator `.throw()` closures inline catch-route bodies + // verbatim; fix any break/continue sentinels they captured from + // this loop body (a user `continue`/`break` inside a `catch`). + fix_break_continue_sentinels_in_catches( + &mut catches[body_catches_before..], + state_id, + after_loop_state, + update_state, + ); } // While-loop containing yield(s) - similar to for-loop @@ -639,6 +649,7 @@ pub fn linearize_body( // state (no separate update); `break` jumps to after_loop. let while_states_before = states.len(); 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); @@ -686,6 +697,15 @@ pub fn linearize_body( after_loop, cond_state, ); + // Async-generator `.throw()` closures inline catch-route bodies + // verbatim; fix any break/continue sentinels they captured from + // this loop body (a user `continue`/`break` inside a `catch`). + fix_break_continue_sentinels_in_catches( + &mut catches[while_catches_before..], + state_id, + after_loop, + cond_state, + ); } // Try-catch/finally containing yield(s) — linearize the try body and diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index 647f04986c..5c401cb1cd 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -1628,6 +1628,49 @@ fn build_dispatch_catch_handler( ) } +/// Replace the synthesized dispatch re-entry `Stmt::Continue` (emitted by +/// `rewrite_break_continue_in_stmts` for a user `break`/`continue`) with a +/// suspend-return. Used when inlining a catch-route body into the async +/// `.throw()` closure, which has no dispatch `while(true)` loop. Mirrors the +/// recursion in `rewrite_break_continue_in_stmt`: descends into `if`/`try` +/// (where the dispatch continue can sit) but stops at nested loops / switch / +/// labeled / closures, whose own `continue`/`break` belong to them. +fn rewrite_dispatch_continue_to_suspend(stmts: &mut Vec) { + for stmt in stmts.iter_mut() { + match stmt { + Stmt::Continue | Stmt::Break => { + *stmt = Stmt::Return(Some(make_iter_result(Expr::Undefined, false))); + } + Stmt::If { + then_branch, + else_branch, + .. + } => { + rewrite_dispatch_continue_to_suspend(then_branch); + if let Some(eb) = else_branch.as_mut() { + rewrite_dispatch_continue_to_suspend(eb); + } + } + Stmt::Try { + body, + catch, + finally, + } => { + rewrite_dispatch_continue_to_suspend(body); + if let Some(c) = catch.as_mut() { + rewrite_dispatch_continue_to_suspend(&mut c.body); + } + if let Some(f) = finally.as_mut() { + rewrite_dispatch_continue_to_suspend(f); + } + } + // Nested loops / switch / labeled / closures own their own + // break/continue — leave them untouched. + _ => {} + } + } +} + fn build_async_catch_route_body( route: &CatchRoute, finallys: &[FinallyRoute], @@ -1668,6 +1711,19 @@ fn build_async_catch_route_body( rewrite_hoisted_lets_in_stmts(&mut rewritten, hoisted_ids); rewrite_yield_to_await_in_stmts(&mut rewritten); rewrite_catch_returns_to_iter_result(&mut rewritten); + // A user `break`/`continue` inside this catch was rewritten by + // `rewrite_break_continue_in_stmts` into `[LocalSet(state, TARGET), + // Stmt::Continue]` — the trailing `Stmt::Continue` re-enters the dispatch + // `while(true)` loop. The async `.throw()` closure has NO dispatch loop + // (it runs the handler, then suspends), so that dangling dispatch-continue + // would be a `continue` with no enclosing loop. The preceding `LocalSet` + // already moved the state to the loop's resume target (cond/update/after- + // loop, fixed up by `fix_break_continue_sentinels_in_catches`), so the + // correct async behavior is to suspend right there: convert the dispatch + // re-entry into a `return { value: undefined, done: false }`. + if !fall_through { + rewrite_dispatch_continue_to_suspend(&mut rewritten); + } // #4374: if this try also has a (sync) finally, a `throw` inside the catch // handler must still run that finally before propagating. The normal diff --git a/crates/perry-transform/src/generator/mod.rs b/crates/perry-transform/src/generator/mod.rs index ddfb1f2135..08bd48b978 100644 --- a/crates/perry-transform/src/generator/mod.rs +++ b/crates/perry-transform/src/generator/mod.rs @@ -25,9 +25,9 @@ mod rewrite_returns; // cross-module symbol here. pub(crate) use break_continue::{ body_contains_yield, collect_hoisted_vars, collect_vars_recursive, - fix_break_continue_sentinels, fix_break_continue_sentinels_in_stmt, - fix_break_continue_sentinels_in_stmts, fix_placeholder_state, rewrite_break_continue_in_stmt, - rewrite_break_continue_in_stmts, + fix_break_continue_sentinels, fix_break_continue_sentinels_in_catches, + fix_break_continue_sentinels_in_stmt, fix_break_continue_sentinels_in_stmts, + fix_placeholder_state, rewrite_break_continue_in_stmt, rewrite_break_continue_in_stmts, }; pub(crate) use helpers::{ alloc_local, make_iter_result, rewrite_hoisted_lets_in_stmt, rewrite_hoisted_lets_in_stmts, From 74f29884df81f03ddabf6bec3261626d1c08bfd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 23:38:43 +0200 Subject: [PATCH 06/17] fix(transform): labeled break/continue from a nested loop in an async generator no longer drops the loop target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `break