diff --git a/crates/perry-codegen/src/lower_call/capture_writeback.rs b/crates/perry-codegen/src/lower_call/capture_writeback.rs index 2cdc7b854..f575e38f0 100644 --- a/crates/perry-codegen/src/lower_call/capture_writeback.rs +++ b/crates/perry-codegen/src/lower_call/capture_writeback.rs @@ -55,7 +55,7 @@ pub(crate) fn emit_class_capture_writeback( let cap_args_start = new_args.len().saturating_sub(cap_params.len()); for (cap_idx, param) in cap_params.iter().enumerate() { - let Some(id_str) = param.name.strip_prefix("__perry_cap_") else { + let Some(name_outer_id) = perry_hir::cap_fields::cap_field_outer_id(¶m.name) else { continue; }; // Prefer position-based lookup using new_args: the arg at index @@ -71,9 +71,9 @@ pub(crate) fn emit_class_capture_writeback( None } }) - // Fallback to the numeric suffix from __perry_cap_ for + // Fallback to the numeric id parsed from the cap name for // call sites that don't supply new_args (empty slice). - .or_else(|| id_str.parse::().ok()); + .or(Some(name_outer_id)); let Some(outer_id) = current_scope_id else { continue; }; diff --git a/crates/perry-codegen/src/lower_call/new_ctor_args.rs b/crates/perry-codegen/src/lower_call/new_ctor_args.rs index 8a198d596..0ec8805e4 100644 --- a/crates/perry-codegen/src/lower_call/new_ctor_args.rs +++ b/crates/perry-codegen/src/lower_call/new_ctor_args.rs @@ -266,10 +266,7 @@ pub(super) fn new_site_args_carry_appended_caps(class: &perry_hir::Class, args: let tail = &args[args.len() - cap_params.len()..]; tail.iter().zip(cap_params.iter()).all(|(arg, p)| { matches!(arg, Expr::LocalGet(id) - if p.name - .strip_prefix("__perry_cap_") - .and_then(|s| s.parse::().ok()) - == Some(*id)) + if perry_hir::cap_fields::cap_field_outer_id(&p.name) == Some(*id)) }) } diff --git a/crates/perry-hir/src/cap_fields.rs b/crates/perry-hir/src/cap_fields.rs new file mode 100644 index 000000000..23f576bea --- /dev/null +++ b/crates/perry-hir/src/cap_fields.rs @@ -0,0 +1,76 @@ +//! Class-capture instance-field naming. +//! +//! A class whose members reference enclosing-scope locals stashes those +//! captured values onto each instance as `this.__perry_cap_*` fields (written +//! by the constructor) and rebinds them in member prologues. The field name +//! must be unique **per defining module**, not just per capture id: local ids +//! restart at 0 in every module, and `super()` runs the parent constructor's +//! stashes on the SHARED instance. With bare `__perry_cap_` names, a +//! derived-class method rebinding its own capture id K read the PARENT +//! MODULE's captured local K whenever the parent chain crossed modules and a +//! parent-side capture happened to share the id — Next.js's +//! `NextNodeServer.getBuildId` (next-server.js) rebound its `_fs` capture and +//! got base-server.js's `_interop_require_wildcard(require("path"))` instead: +//! `_fs.default.readFileSync(...)` dispatched on the `path` namespace, +//! returned undefined, and `.trim()` killed the server at boot. +//! +//! The name therefore embeds a stable per-module salt: +//! `__perry_cap_m`. Same-module inheritance keeps sharing +//! parent stashes (equal salt ⇒ equal names); cross-module chains are +//! isolated. Sites that need the outer id back (ctor-arg alignment, factory +//! specialization, capture writeback) parse the leading digits via +//! [`cap_field_outer_id`] instead of assuming the whole suffix is numeric. + +/// Prefix shared by every class-capture field/param name. +pub const CAP_FIELD_PREFIX: &str = "__perry_cap_"; + +/// Instance-field / ctor-param name for a class-captured local. `salt` is the +/// defining module's stable salt (`LoweringContext::cap_salt`). +pub fn cap_field_name(salt: u64, id: u32) -> String { + format!("{CAP_FIELD_PREFIX}{id}m{:012x}", salt & 0xFFFF_FFFF_FFFF) +} + +/// Parse the outer local id from a cap field/param name. Accepts both the +/// salted `__perry_cap_m` form and the legacy `__perry_cap_` +/// (still produced by pre-salt HIR in caches/tests). +pub fn cap_field_outer_id(name: &str) -> Option { + let suffix = name.strip_prefix(CAP_FIELD_PREFIX)?; + let end = suffix + .bytes() + .position(|b| !b.is_ascii_digit()) + .unwrap_or(suffix.len()); + if end == 0 { + return None; + } + suffix[..end].parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_roundtrip() { + let n = cap_field_name(0xDEAD_BEEF_CAFE, 13); + assert!(n.starts_with(CAP_FIELD_PREFIX)); + assert_eq!(cap_field_outer_id(&n), Some(13)); + } + + #[test] + fn legacy_unsalted_parses() { + assert_eq!(cap_field_outer_id("__perry_cap_7"), Some(7)); + } + + #[test] + fn distinct_modules_distinct_names() { + assert_ne!(cap_field_name(1, 13), cap_field_name(2, 13)); + assert_eq!(cap_field_name(9, 4), cap_field_name(9, 4)); + } + + #[test] + fn non_cap_names_reject() { + assert_eq!(cap_field_outer_id("__perry_capX"), None); + assert_eq!(cap_field_outer_id("__perry_cap_"), None); + assert_eq!(cap_field_outer_id("distDir"), None); + } +} diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index 5d12c7b51..d5593bb33 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -5,6 +5,7 @@ pub mod analysis; pub mod audit; +pub mod cap_fields; pub mod capability; mod class_accessors; pub mod deferral; diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 42a190d6d..443c5e4af 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -1149,6 +1149,13 @@ impl LoweringContext { .map(|(_, params, ret)| (params, ret)) } + /// Stable per-module salt for class-capture field names (see + /// `crate::cap_fields`). Reuses the tagged-template site salt — a stable + /// hash of the module's source path. + pub(crate) fn cap_salt(&self) -> u64 { + self.tagged_template_site_salt + } + pub(crate) fn register_native_module( &mut self, local_name: String, diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index b20655ff2..4860bf2ae 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -62,6 +62,15 @@ fn collect_assigned_deep_expr(expr: &Expr, out: &mut HashSet) { /// Detect shared-mutable class captures and rewrite them to one-element array /// boxes. A no-op when there are none (the common case), so non-capturing / /// immutable-capture code is left byte-identical. + +/// Does `name` denote a class-capture field/param for one of `ids`? +/// Matches by parsed outer id (see `crate::cap_fields`): the names carry a +/// per-module salt, and these per-module passes only ever compare names +/// minted by this module's own lowering. +fn is_cap_name_of(name: &str, ids: &HashSet) -> bool { + crate::cap_fields::cap_field_outer_id(name).is_some_and(|id| ids.contains(&id)) +} + pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // Bisection escape hatch (#5951): disable the desugar to isolate its effect. if std::env::var("PERRY_NO_5951").is_ok() { @@ -152,10 +161,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // Match them BY NAME within each member and rewrite only that member's body // with its own ids (never the declaring `shared` set — the declaring `Let` // that gets array-wrapped lives outside the class). - let targets: HashSet = all_shared - .iter() - .map(|id| format!("__perry_cap_{}", id)) - .collect(); + let targets: &HashSet = &all_shared; let no_shared: HashSet = HashSet::new(); for c in &mut module.classes { for m in &mut c.methods { @@ -189,7 +195,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { collect_let_names_expr(key, &mut names); } for (id, n) in names { - if targets.contains(&n) { + if is_cap_name_of(&n, targets) { ctor_ids.insert(id); } } @@ -259,9 +265,9 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { /// The ids in `f`'s own scope (params + `Let`s, descending into nested /// closures) whose NAME is a flagged `__perry_cap_` rebind target. -fn collect_fn_target_ids(f: &Function, targets: &HashSet, out: &mut HashSet) { +fn collect_fn_target_ids(f: &Function, targets: &HashSet, out: &mut HashSet) { for p in &f.params { - if targets.contains(&p.name) { + if is_cap_name_of(&p.name, targets) { out.insert(p.id); } } @@ -270,7 +276,7 @@ fn collect_fn_target_ids(f: &Function, targets: &HashSet, out: &mut Hash collect_let_names_stmt(s, &mut names); } for (id, n) in names { - if targets.contains(&n) { + if is_cap_name_of(&n, targets) { out.insert(id); } } @@ -281,7 +287,7 @@ fn collect_fn_target_ids(f: &Function, targets: &HashSet, out: &mut Hash /// its params and deep `Let`s/closure params; see `retain_unambiguous`). fn rewrite_member_scoped( f: &mut Function, - targets: &HashSet, + targets: &HashSet, no_shared: &HashSet, ) { let mut ids: HashSet = HashSet::new(); @@ -350,19 +356,16 @@ fn collect_declared_counts_expr(expr: &Expr, out: &mut HashMap) { } fn retype_capture_holders(module: &mut Module, shared: &HashSet) { - let targets: HashSet = shared - .iter() - .map(|id| format!("__perry_cap_{}", id)) - .collect(); + let targets: &HashSet = shared; for c in &mut module.classes { for f in &mut c.fields { - if targets.contains(&f.name) { + if is_cap_name_of(&f.name, targets) { f.ty = Type::Any; } } let mut retype_fn = |f: &mut Function| { for p in &mut f.params { - if targets.contains(&p.name) { + if is_cap_name_of(&p.name, targets) { p.ty = Type::Any; } } @@ -389,15 +392,15 @@ fn retype_capture_holders(module: &mut Module, shared: &HashSet) { } } -fn retype_lets_in_stmts(stmts: &mut [Stmt], targets: &HashSet) { +fn retype_lets_in_stmts(stmts: &mut [Stmt], targets: &HashSet) { for s in stmts.iter_mut() { retype_lets_in_stmt(s, targets); } } -fn retype_lets_in_stmt(stmt: &mut Stmt, targets: &HashSet) { +fn retype_lets_in_stmt(stmt: &mut Stmt, targets: &HashSet) { if let Stmt::Let { name, ty, init, .. } = stmt { - if targets.contains(name) { + if is_cap_name_of(name, targets) { *ty = Type::Any; } if let Some(e) = init { @@ -446,7 +449,7 @@ fn retype_lets_in_stmt(stmt: &mut Stmt, targets: &HashSet) { } } -fn retype_lets_in_expr(expr: &mut Expr, targets: &HashSet) { +fn retype_lets_in_expr(expr: &mut Expr, targets: &HashSet) { if let Expr::Closure { body, .. } = expr { retype_lets_in_stmts(body, targets); } @@ -492,12 +495,13 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Hash } fn class_mutates_capture(c: &Class, id: LocalId) -> bool { - let target = format!("__perry_cap_{}", id); let id_name = collect_class_names(c); let assigned = collect_class_assigned(c); - assigned - .iter() - .any(|aid| id_name.get(aid).is_some_and(|n| *n == target)) + assigned.iter().any(|aid| { + id_name + .get(aid) + .is_some_and(|n| crate::cap_fields::cap_field_outer_id(n) == Some(id)) + }) } /// id -> name across a class: every member function's PARAMS (a field-init diff --git a/crates/perry-hir/src/lower_decl/class_captures.rs b/crates/perry-hir/src/lower_decl/class_captures.rs index 6712ff7e7..865dfa860 100644 --- a/crates/perry-hir/src/lower_decl/class_captures.rs +++ b/crates/perry-hir/src/lower_decl/class_captures.rs @@ -18,6 +18,7 @@ pub fn synthesize_class_captures( constructor: &mut Option, static_methods: &mut Vec, ) { + let cap_salt = ctx.cap_salt(); let module_level_ids = ctx.module_level_ids.clone(); let outer_scope_ids: std::collections::HashSet = ctx.locals.iter().map(|(_, id, _)| *id).collect(); @@ -140,7 +141,9 @@ pub fn synthesize_class_captures( let inherited_cap_ids: std::collections::HashSet = captures_vec .iter() .copied() - .filter(|cid| inherited_cap_field_names.contains(&format!("__perry_cap_{}", cid))) + .filter(|cid| { + inherited_cap_field_names.contains(&crate::cap_fields::cap_field_name(cap_salt, *cid)) + }) .collect(); // 1. Hidden fields keyed by outer id, skipping inherited. @@ -149,7 +152,7 @@ pub fn synthesize_class_captures( continue; } fields.push(ClassField { - name: format!("__perry_cap_{}", cid), + name: crate::cap_fields::cap_field_name(cap_salt, cid), key_expr: None, ty: Type::Any, init: None, @@ -161,7 +164,7 @@ pub fn synthesize_class_captures( if let Some(existing) = ctx.lookup_class_field_names(name) { let mut updated: Vec = existing.to_vec(); for &cid in &captures_vec { - let field_name = format!("__perry_cap_{}", cid); + let field_name = crate::cap_fields::cap_field_name(cap_salt, cid); if !updated.contains(&field_name) { updated.push(field_name); } @@ -203,7 +206,7 @@ pub fn synthesize_class_captures( // enough to defer to a follow-up. let field_propagation: std::collections::HashMap = captures_vec .iter() - .map(|&cid| (cid, format!("__perry_cap_{}", cid))) + .map(|&cid| (cid, crate::cap_fields::cap_field_name(cap_salt, cid))) .collect(); // Helper closure: build a fresh-id map for one function's body, @@ -234,7 +237,7 @@ pub fn synthesize_class_captures( // (same machinery as the ctor param rebinds above). prologue.push(Stmt::Let { id: new_id, - name: format!("__perry_cap_{}", outer_id), + name: crate::cap_fields::cap_field_name(cap_salt, outer_id), ty, mutable: true, init: Some(Expr::ClassCaptureValue { @@ -242,7 +245,7 @@ pub fn synthesize_class_captures( index: index as u32, fallback: Some(Box::new(Expr::PropertyGet { object: Box::new(Expr::This), - property: format!("__perry_cap_{}", outer_id), + property: crate::cap_fields::cap_field_name(cap_salt, outer_id), })), prefer_fallback: true, }), @@ -334,7 +337,7 @@ pub fn synthesize_class_captures( id_map.insert(outer_id, new_id); prologue.push(Stmt::Let { id: new_id, - name: format!("__perry_cap_{}", outer_id), + name: crate::cap_fields::cap_field_name(cap_salt, outer_id), ty: captured_outer_types .get(&outer_id) .cloned() @@ -377,7 +380,7 @@ pub fn synthesize_class_captures( id_map.insert(outer_id, new_id); prologue.push(Stmt::Let { id: new_id, - name: format!("__perry_cap_{}", outer_id), + name: crate::cap_fields::cap_field_name(cap_salt, outer_id), ty: captured_outer_types .get(&outer_id) .cloned() @@ -494,7 +497,7 @@ pub fn synthesize_class_captures( .unwrap_or(Type::Any); ctor.params.push(Param { id: fresh_param_id, - name: format!("__perry_cap_{}", outer_id), + name: crate::cap_fields::cap_field_name(cap_salt, outer_id), ty, default: None, decorators: Vec::new(), @@ -518,7 +521,7 @@ pub fn synthesize_class_captures( ))); assignment_stmts.push(Stmt::Expr(Expr::PropertySet { object: Box::new(Expr::This), - property: format!("__perry_cap_{}", outer_id), + property: crate::cap_fields::cap_field_name(cap_salt, outer_id), value: Box::new(Expr::LocalGet(fresh_param_id)), })); } diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index 6fa7b3107..fd333c3cf 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -357,6 +357,13 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( } STRINGIFY_STACK.with(|s| s.borrow_mut().push(ptr as usize)); + // GC-safety: same rooting discipline as the array variant above — the + // replacer / toJSON / getter callbacks can trigger a moving GC, so every + // raw pointer derived from `ptr` / `keys_arr` / `replacer` must be + // re-derived from a rewritable root after each callback. + let gc_scope = crate::gc::RuntimeHandleScope::new(); + let obj_root = gc_scope.root_raw_const_ptr(ptr); + let replacer_root = gc_scope.root_raw_const_ptr(replacer); let obj = ptr as *const crate::ObjectHeader; let num_fields = (*obj).field_count; let Some(keys_arr) = super::stringify::object_keys_array_checked(obj) else { @@ -366,11 +373,8 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( buf.push_str("{}"); return; }; + let keys_root = gc_scope.root_raw_const_ptr(keys_arr); let keys_len = (*keys_arr).length; - let keys_elements = - (keys_arr as *const u8).add(std::mem::size_of::()) as *const f64; - let fields_ptr = - (ptr as *const u8).add(std::mem::size_of::()) as *const f64; // #5989 (mirrors the plain-stringify #307 fix): iterate up to keys_len, not // min(num_fields, keys_len). Objects with ≥9 fields cap field_count at the @@ -388,6 +392,13 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( buf.push('{'); let mut first = true; for f in 0..actual_fields { + let obj = obj_root.get_raw_const_ptr::(); + let keys_elements = (keys_root.get_raw_const_ptr::()) + .add(std::mem::size_of::()) + as *const f64; + let fields_ptr = (obj_root.get_raw_const_ptr::()) + .add(std::mem::size_of::()) as *const f64; + let replacer = replacer_root.get_raw_const_ptr::(); // Skip non-enumerable own keys before invoking the replacer. if filter_non_enum && f < keys_len @@ -440,7 +451,7 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( replacer, key_f64_for_replacer, field_after_to_json, - holder_value(ptr), + holder_value(obj_root.get_raw_const_ptr::()), ); let replaced_bits = replaced.to_bits(); @@ -507,9 +518,20 @@ pub(crate) unsafe fn stringify_array_with_replacer_pretty( } STRINGIFY_STACK.with(|s| s.borrow_mut().push(ptr as usize)); + // GC-safety (#gscmaster ~10-render crash): the replacer / toJSON callbacks + // run arbitrary JS, which allocates — a minor GC mid-loop can PROMOTE + // (move) this array, the replacer closure, or both. The raw `elements` + // base and `replacer` pointer would then dangle: the next `*elements + // .add(i)` read garbage f64s off the old nursery copy and the NaN-boxed + // "pointer" they produced faulted in whatever shape-probe touched it + // first (temporal::dispatch::get_property, url::search_params, …). + // Root both in a RuntimeHandleScope (rewritten on evacuation) and + // re-derive the raw pointers after every callback. + let gc_scope = crate::gc::RuntimeHandleScope::new(); + let arr_root = gc_scope.root_raw_const_ptr(ptr); + let replacer_root = gc_scope.root_raw_const_ptr(replacer); let arr = ptr as *const crate::ArrayHeader; let len = (*arr).length; - let elements = (arr as *const u8).add(std::mem::size_of::()) as *const f64; if len == 0 { buf.push_str("[]"); @@ -530,6 +552,9 @@ pub(crate) unsafe fn stringify_array_with_replacer_pretty( buf.push_str(indent); } } + let arr_base = arr_root.get_raw_const_ptr::(); + let elements = arr_base.add(std::mem::size_of::()) as *const f64; + let replacer = replacer_root.get_raw_const_ptr::(); let elem = *elements.add(i as usize); // #5989: a sparse-array HOLE slot must surface to toJSON / the replacer // as `undefined` (spec: Get() on a missing index yields undefined), @@ -550,7 +575,12 @@ pub(crate) unsafe fn stringify_array_with_replacer_pretty( let key_f64 = nanbox_string_f64(idx_ptr); let elem_after_to_json = apply_to_json_keyed(elem, key_f64); - let replaced = call_replacer(replacer, key_f64, elem_after_to_json, holder_value(ptr)); + let replaced = call_replacer( + replacer, + key_f64, + elem_after_to_json, + holder_value(arr_root.get_raw_const_ptr::()), + ); let replaced_bits = replaced.to_bits(); // Array holes / undefined / functions become null (per JSON spec). diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index d469ebbab..2f87cf3e6 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -653,8 +653,37 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // `None`. Fall back to the fetch-parent kind registered against the // instance's class at module init (via `js_register_class_parent_dynamic`, // where the alias resolved correctly) so the native handle still attaches. + // A parent value that resolves to a USER constructor (ClassRef, class + // object, or closure) must take the value-super dispatch below — its own + // constructor body has to run on `this` (and its own `super()` leg will + // attach the native handle when it reaches the builtin). The chain-walk + // fallback exists ONLY for a stale/unresolvable alias of the builtin + // itself; letting it preempt a live user parent skipped every + // intermediate constructor in `class Hint extends NextRequest extends + // Request` — Next.js middleware's NextRequestHint lost the parent's + // symbol-keyed internal state (`this[INTERNALS]`), so `request.nextUrl` + // threw on every request. + let parent_is_user_ctor = { + let bits = parent_val.to_bits(); + const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; + const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; + const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; + const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + match bits & TAG_MASK { + INT32_TAG => true, // ClassRef + POINTER_TAG => { + let p = (bits & PTR_MASK) as usize; + crate::closure::is_closure_ptr(p) + || super::super::class_registry::is_class_object_ptr(p as *const u8) + } + _ => false, + } + }; let kind = super::super::class_registry::identify_global_builtin_constructor(parent_val) .or_else(|| { + if parent_is_user_ctor { + return None; + } let obj = subclass_this_object_ptr(this_box)?; match super::super::class_registry::fetch_parent_kind_in_chain( crate::object::js_object_get_class_id(obj), diff --git a/crates/perry-transform/src/inline/factory_specialize.rs b/crates/perry-transform/src/inline/factory_specialize.rs index 12f9be737..a1f84dcb4 100644 --- a/crates/perry-transform/src/inline/factory_specialize.rs +++ b/crates/perry-transform/src/inline/factory_specialize.rs @@ -162,9 +162,7 @@ pub fn specialize_captured_class_factories(module: &mut Module) { .as_ref() .map(|c| { c.params.iter().any(|p| { - p.name - .strip_prefix("__perry_cap_") - .and_then(|suffix| suffix.parse::().ok()) + perry_hir::cap_fields::cap_field_outer_id(&p.name) .map(|outer_id| param_set.contains(&outer_id)) .unwrap_or(false) }) @@ -786,8 +784,8 @@ pub fn specialize_captured_class_factories(module: &mut Module) { .collect(); if let Some(ctor) = &class.constructor { for p in &ctor.params { - if let Some(suffix) = p.name.strip_prefix("__perry_cap_") { - if let Ok(outer_id) = suffix.parse::() { + if let Some(outer_id) = perry_hir::cap_fields::cap_field_outer_id(&p.name) { + { if let Some(idx) = param_ids.iter().position(|id| *id == outer_id) { let arg_expr = padded_args[idx].clone(); subst.insert(p.id, arg_expr); diff --git a/test-files/_helpers/cross_module_cap_ids_base.ts b/test-files/_helpers/cross_module_cap_ids_base.ts new file mode 100644 index 000000000..6c07ceaea --- /dev/null +++ b/test-files/_helpers/cross_module_cap_ids_base.ts @@ -0,0 +1,26 @@ +// Helper module for test_gap_cross_module_class_capture_ids.ts. +// +// Shaped like next/dist/server/base-server.js: a class whose members read +// module-scope locals (class captures), including a virtual method the +// CONSTRUCTOR calls — so the derived override runs while only the BASE +// constructor's `__perry_cap_*` stashes exist on the instance. The captured +// locals here are declared in the same order as the derived module's, so the +// capture LOCAL IDS collide across the two modules (they restart per module). +const baseInterop = { default: "BASE-path-ns" }; +const baseExtra = "base-extra"; + +export class BaseServer { + tag: string; + constructor() { + // Virtual call from the base ctor — lands in the derived override before + // the derived ctor's own capture stashes run (the Next.js + // `this.buildId = this.getBuildId()` shape). + this.tag = this.describe(); + } + describe(): string { + return "base:" + baseInterop.default + ":" + baseExtra; + } + baseView(): string { + return baseInterop.default; + } +} diff --git a/test-files/_helpers/cross_module_fetch_super_base.ts b/test-files/_helpers/cross_module_fetch_super_base.ts new file mode 100644 index 000000000..f4c323602 --- /dev/null +++ b/test-files/_helpers/cross_module_fetch_super_base.ts @@ -0,0 +1,24 @@ +// Helper for test_gap_cross_module_fetch_subclass_super.ts — the +// next/dist/server/web NextRequest shape: a class extending the NATIVE +// `Request`, storing symbol-keyed internal state in its constructor and +// exposing it through getters. Lives in its own module so the subclass's +// `super(...)` crosses a module boundary (value-super dispatch). +const INTERNALS = Symbol("internal request"); + +export class BaseRequest extends Request { + constructor(input: any, init: any = {}) { + const url = typeof input !== "string" && "url" in input ? input.url : String(input); + if (input instanceof Request) { + super(input, init); + } else { + super(url, init); + } + (this as any)[INTERNALS] = { nextUrl: "NU:" + url, srcUrl: url }; + } + get nextUrl(): string { + return (this as any)[INTERNALS].nextUrl; + } + get srcUrl(): string { + return (this as any)[INTERNALS].srcUrl; + } +} diff --git a/test-files/test_gap_cross_module_class_capture_ids.ts b/test-files/test_gap_cross_module_class_capture_ids.ts new file mode 100644 index 000000000..b4d7c50e8 --- /dev/null +++ b/test-files/test_gap_cross_module_class_capture_ids.ts @@ -0,0 +1,46 @@ +// Regression: cross-module base/derived class-capture field collision. +// +// Class captures are stashed on the instance as `this.__perry_cap_` +// fields by each class's constructor. Local ids restart per module, and +// `super()` runs the PARENT constructor's stashes on the SHARED instance — +// so with bare per-id names, a derived method rebinding its own capture id K +// read the PARENT MODULE's captured local K whenever the ids collided. +// +// This is exactly how Next.js standalone died at boot: base-server.js +// captures `_path = _interop_require_wildcard(require("path"))` and +// next-server.js captures `_fs = _interop_require_default(require("fs"))` +// under the same local id; `Server`'s ctor calls the virtual +// `this.getBuildId()`, whose `_fs.default.readFileSync(...)` rebind found +// base-server's `_path` wildcard instead — `readFileSync` dispatched on the +// `path` namespace, returned undefined, and `.trim()` threw before Ready. +// +// The fix salts the field names per defining module +// (`__perry_cap_m`, crates/perry-hir/src/cap_fields.rs), keeping +// same-module inheritance sharing intact. +// +// Output is byte-identical to `node --experimental-strip-types`. + +import { BaseServer } from "./_helpers/cross_module_cap_ids_base.ts"; + +// Same declaration order as the helper module → colliding capture local ids. +const derivedInterop = { default: "DERIVED-fs-ns" }; +const derivedExtra = "derived-extra"; + +class DerivedServer extends BaseServer { + constructor() { + super(); + } + // Virtual override the BASE ctor calls before this module's stashes exist. + describe(): string { + return "derived:" + derivedInterop.default + ":" + derivedExtra; + } +} + +const d = new DerivedServer(); +// Pre-fix: tag read base-server's captures ("derived:BASE-path-ns:base-extra") +// or undefined-flavored garbage. Post-fix: the derived override sees its own. +console.log("tag:", d.tag); +// After construction both stash sets exist — reads must stay per-class. +console.log("post:", d.describe(), "|", d.baseView()); +// A base instance is unaffected either way. +console.log("base:", new BaseServer().tag); diff --git a/test-files/test_gap_cross_module_fetch_subclass_super.ts b/test-files/test_gap_cross_module_fetch_subclass_super.ts new file mode 100644 index 000000000..f892e90c8 --- /dev/null +++ b/test-files/test_gap_cross_module_fetch_subclass_super.ts @@ -0,0 +1,47 @@ +// Regression: `super(...)` skipped an intermediate user constructor when a +// grandparent in the chain was the native fetch `Request`. +// +// `js_fetch_or_value_super`'s stale-alias fallback walked the instance's +// WHOLE parent chain for a recorded Request/Response parent — so for +// `class Hint extends BaseRequest extends Request` (base in another module), +// the Hint's `super(input, init)` was classified as a DIRECT native-Request +// super: the runtime constructed the native handle and returned without ever +// invoking BaseRequest's constructor body. Its symbol-keyed internal state +// (`this[INTERNALS] = {...}`) never existed, so every getter read +// `undefined.` and threw. +// +// This is Next.js middleware's exact shape (NextRequestHint extends +// NextRequest extends Request): every request 500'd with "Cannot read +// properties of undefined (reading 'nextUrl')". The fix lets a parent value +// that resolves to a USER constructor (ClassRef / class object / closure) +// take the value-super dispatch — its own `super()` leg attaches the native +// handle when it reaches the builtin — and keeps the chain-walk fallback +// only for a stale/unresolvable alias of the builtin itself. +// +// Output is byte-identical to `node --experimental-strip-types`. + +import { BaseRequest } from "./_helpers/cross_module_fetch_super_base.ts"; + +class RequestHint extends BaseRequest { + sourcePage: string; + constructor(params: { page: string; input: any; init: any }) { + super(params.input, params.init); + this.sourcePage = params.page; + } +} + +// Construct from a native Request instance (the middleware-adapter shape). +const native = new Request("https://x.test/de"); +const hint = new RequestHint({ page: "/", input: native, init: {} }); +console.log("sourcePage:", hint.sourcePage); +console.log("nextUrl:", hint.nextUrl); +console.log("srcUrl:", hint.srcUrl); +console.log("native method:", hint.method); + +// String input takes the other super arm. +const hint2 = new RequestHint({ page: "/b", input: "https://y.test/en", init: {} }); +console.log("hint2:", hint2.nextUrl, hint2.sourcePage); + +// The direct subclass still works (its ctor IS the one that extends Request). +const base = new BaseRequest("https://z.test/fr", {}); +console.log("base:", base.nextUrl, base.method);