From 85776241ed3a9c984b0561376e476870e3243863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 15 Jun 2026 11:38:49 +0200 Subject: [PATCH] fix(transform): scan class field initializers when computing max FuncId (#5143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async/generator state-machine transforms seed their fresh-FuncId counter from `compute_max_func_id(module) + 1`. Both scanners walked module functions, init, and class method/constructor/accessor bodies — but NOT class FIELD initializers. An arrow-function field (`request = (input) => ...`) holds a closure whose FuncId is reachable only through `class.fields[].init`, so it was invisible to the scan. When a class also lowered a generator/async step (Hono's `compose` dispatch goes through `try { await … } catch {}`), the transform minted a synthesized step FuncId that collided with the field-initializer's FuncId. At codegen the step body won, so the class field bound the wrong function: `new Hono().request('/json')` returned a stray iterator closure instead of the request handler, and `await app.request('/json')` resolved to undefined — `res.status` then threw "Cannot read properties of undefined". Fix: extend both `compute_max_func_id` (generator) and `compute_max_func_id_module` (async) to also scan field/static-field initializers, computed-member functions/keys, the extends expression, and module globals — every place in the FuncId namespace that nests a closure. Only raises the max, so it can never introduce a collision. Adds unit tests asserting field-initializer closure FuncIds are counted. --- .../perry-transform/src/async_to_generator.rs | 28 +++++ .../perry-transform/src/generator/id_scan.rs | 102 ++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/crates/perry-transform/src/async_to_generator.rs b/crates/perry-transform/src/async_to_generator.rs index ffde2050da..f9ae366afb 100644 --- a/crates/perry-transform/src/async_to_generator.rs +++ b/crates/perry-transform/src/async_to_generator.rs @@ -225,22 +225,50 @@ fn compute_max_func_id_module(module: &Module) -> perry_types::FuncId { for stmt in &module.init { scan_stmt_for_max_closure_id(stmt, &mut max_closure_id); } + for global in &module.globals { + if let Some(init) = &global.init { + scan_expr_for_max_closure_id(init, &mut max_closure_id); + } + } for class in &module.classes { for mb in &class.methods { + m = m.max(mb.id); scan_stmts_for_max_closure_id(&mb.body, &mut max_closure_id); } for mb in &class.static_methods { + m = m.max(mb.id); scan_stmts_for_max_closure_id(&mb.body, &mut max_closure_id); } if let Some(ctor) = &class.constructor { + m = m.max(ctor.id); scan_stmts_for_max_closure_id(&ctor.body, &mut max_closure_id); } for g in &class.getters { + m = m.max(g.1.id); scan_stmts_for_max_closure_id(&g.1.body, &mut max_closure_id); } for s in &class.setters { + m = m.max(s.1.id); scan_stmts_for_max_closure_id(&s.1.body, &mut max_closure_id); } + for member in &class.computed_members { + m = m.max(member.function.id); + scan_stmts_for_max_closure_id(&member.function.body, &mut max_closure_id); + scan_expr_for_max_closure_id(&member.key_expr, &mut max_closure_id); + } + // Issue #5143: scan class field initializers — they hold closures + // whose func_ids must not be reused by the async/for-of desugar. + for field in class.fields.iter().chain(class.static_fields.iter()) { + if let Some(init) = &field.init { + scan_expr_for_max_closure_id(init, &mut max_closure_id); + } + if let Some(key_expr) = &field.key_expr { + scan_expr_for_max_closure_id(key_expr, &mut max_closure_id); + } + } + if let Some(extends_expr) = &class.extends_expr { + scan_expr_for_max_closure_id(extends_expr, &mut max_closure_id); + } } m.max(max_closure_id) } diff --git a/crates/perry-transform/src/generator/id_scan.rs b/crates/perry-transform/src/generator/id_scan.rs index c3acf6ab47..75cc79e155 100644 --- a/crates/perry-transform/src/generator/id_scan.rs +++ b/crates/perry-transform/src/generator/id_scan.rs @@ -263,6 +263,25 @@ pub fn compute_max_func_id(module: &Module) -> FuncId { for member in &class.computed_members { scan_expr_for_max_func(&member.key_expr, &mut max_id); } + // Issue #5143: class FIELD initializers (`request = (input) => ...`) + // and computed-key expressions also live in this FuncId namespace. + // Their closures are NOT reachable through any method/constructor + // body, so without scanning them the iterator/generator + // state-machine transform can reuse a field-initializer closure's + // FuncId for a synthesized step function — at codegen the step body + // wins and the class field ends up bound to the wrong function + // (Hono's `app.request()` returned a stray iterator closure). + for field in class.fields.iter().chain(class.static_fields.iter()) { + if let Some(init) = &field.init { + scan_expr_for_max_func(init, &mut max_id); + } + if let Some(key_expr) = &field.key_expr { + scan_expr_for_max_func(key_expr, &mut max_id); + } + } + if let Some(extends_expr) = &class.extends_expr { + scan_expr_for_max_func(extends_expr, &mut max_id); + } } max_id } @@ -440,4 +459,87 @@ mod tests { "closure param LocalId inside ObjectAssign sources" ); } + + fn arrow_field(name: &str, func_id: FuncId) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Any, + init: Some(Expr::Closure { + func_id, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::Integer(0)))], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + }), + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } + } + + fn class_with_fields(name: &str, fields: Vec) -> Class { + Class { + id: 1, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + fields, + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + } + } + + /// #5143: an arrow-function CLASS FIELD initializer (`request = (input) + /// => ...`) carries a closure whose FuncId is reachable only through + /// `class.fields[].init` — no method/constructor body holds it. Before + /// the fix, `compute_max_func_id` skipped class fields, so the + /// generator/async state-machine transform reused that field's FuncId + /// for a synthesized step function; at codegen the step body won and the + /// class field bound the wrong function (Hono's `app.request()` returned + /// undefined). The scan must see field-initializer closures. + #[test] + fn class_field_initializer_closures_visible_to_max_func_id() { + let mut module = Module::new("test"); + module + .classes + .push(class_with_fields("Hono", vec![arrow_field("request", 50)])); + assert_eq!( + compute_max_func_id(&module), + 50, + "field-initializer closure FuncId must be counted" + ); + } + + /// Companion: static-field initializer closures live in the same FuncId + /// namespace and must also be visible. + #[test] + fn static_field_initializer_closures_visible_to_max_func_id() { + let mut module = Module::new("test"); + let mut class = class_with_fields("C", Vec::new()); + class.static_fields.push(arrow_field("handler", 73)); + module.classes.push(class); + assert_eq!(compute_max_func_id(&module), 73); + } }