diff --git a/Cargo.lock b/Cargo.lock index e25c5c14ec..32127e3e0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6291,6 +6291,7 @@ dependencies = [ "stacker", "swc_common", "swc_ecma_ast", + "swc_ecma_visit", "thiserror 1.0.69", ] diff --git a/changelog.d/8882-late-bound-class-new.md b/changelog.d/8882-late-bound-class-new.md new file mode 100644 index 0000000000..f1c9e318fb --- /dev/null +++ b/changelog.d/8882-late-bound-class-new.md @@ -0,0 +1 @@ +Fix a `new ()` whose constructor is not statically resolvable at lowering time throwing a nameless `ReferenceError: identifier is not defined` at module init (#8882, the second instance of #8730's class). The unresolved-`new` guard added in #8643 decided the miss at compile time, but JS binds the constructor reference when the `new` executes, and two shapes are invisible to the lowering-time lookups: a class declared in a function body lowered LATER, and a global that exists only at runtime. The CJS wrap produces the first one routinely — it hoists top-level classes out of the module IIFE but leaves some inside (here Next's `server/lib/lru-cache.js` `SentinelNode`, whose doc comment closes on the `class` line so the textual hoister never sees it), so the hoisted `LRUCache` constructor's `new SentinelNode()` was lowered before the IIFE body registered `SentinelNode`, and the whole Next.js App Route application died at init under Coop. A module-wide pre-scan now records every class declaration name at any depth; a name in that set keeps the late-bound by-name construction codegen resolves through the module class table (the pre-#8643 behaviour), and any other name is read off `globalThis` when the `new` runs via `js_global_get_or_throw_unresolved`, so a runtime-created constructor works and a true miss throws the spec `ReferenceError: is not defined` — with the identifier, as #8730 asked. The bare-identifier arm shares the same helper so the two cannot drift. diff --git a/changelog.d/8893-class-registry-per-image.md b/changelog.d/8893-class-registry-per-image.md new file mode 100644 index 0000000000..6e78a36fea --- /dev/null +++ b/changelog.d/8893-class-registry-per-image.md @@ -0,0 +1,59 @@ +### Fixed + +- **Runtime: class registries are per image, so one process can host several + Perry applications.** Every class-id-keyed table module init writes — + `CLASS_VTABLE_REGISTRY` (instance methods / getters / setters), + `CLASS_STATIC_METHODS`, `CLASS_STATIC_ACCESSORS`, `CLASS_CONSTRUCTORS` and + their flags, the parent-edge map and its dense mirror, `CLASS_NAMES`, + `CLASS_LENGTHS`, `REGISTERED_CLASS_IDS`, the bind-length tables, the + `extends Error` / `DataView` / typed-array marks, the `Symbol.hasInstance` / + `Symbol.toStringTag` hooks, the generic-origin and fetch-parent maps, + `ANON_SHAPE_CLASS_IDS` — was a process-global `static` keyed by + compile-time class id (#8546). Class ids are assigned by codegen from a small + sequential counter, so N dlopen'd copies of an application register the SAME + ids with DIFFERENT `func_ptr`s (each image's own code addresses), and + `HashMap::insert` is last-writer-wins: after the last image's init, every + class of every earlier image dispatched into the last image's code. In a Coop + daemon hosting several Next.js deployments only the last-initialised one + served; the others died on their first by-name resolution with + `TypeError: value is not a function`. No write order over a shared table can + work (first-wins for methods leaves every vtable a mix of two images; + first-owner for every entry point leaves later images unable to initialise), + so the tables are now per **image**. + + The model (`crates/perry-runtime/src/object/class_image.rs`): the tables live + in one `ClassImageTables` per image, reached through a thread-local handle. + `js_gc_init` — the first runtime call codegen emits in both `main` and + `perry_module_init`, on the thread that runs that image's module init — gives + the thread its own image (the first thread to enter owns the *primary* + image; every later one gets a fresh image). `perry/thread` workers (`spawn`, + `parallelMap`, `parallelFilter`) and `worker_threads` Workers adopt their + spawner's image before they run anything, because they never run module init + and must dispatch through the spawner's tables. A thread that neither entered + nor adopted — a pump firing JS on the primary heap's behalf (Android's UI + thread), a reactor thread, a libtest thread — reads and writes the primary + image, which is exactly the process-global table it saw before; a program + with one image is behaviourally unchanged. Keying by thread alone was + rejected because those pump/worker threads run JS that dispatches through + these tables without ever running init; keying by `AgentId` was rejected for + #8528's reason (a host's app thread is a plain `std::thread::spawn` that + never claims an agent). Each former `static RwLock<..>` is now a `static + ImageTable>` whose `read()` / `write()` resolve the calling + thread's image and return the same guard types, so the ~100 call sites are + unchanged. The `RegistryLatch`es and `VTABLE_GEN` stay process-global on + purpose: a latch armed by any image only ever costs another image the slow + path, never a wrong answer. + + Regression tests (`object::class_image::tests`): two application threads + register the same class id with different method addresses and each + dispatches to its own (sabotage-verified: with `enter_current_thread_image` + a no-op, the last writer wins and the test fails); a spawned worker shares + its spawner's image while a second application sees neither; a thread with + no image reads the primary. Cost: the dense parent-edge read + (`get_parent_class_id`, the hottest class-registry read) now goes through + the thread-local image resolution (a cached-TLS load) before the indexed + atomic load, and each image allocates its 256 KiB dense table on the heap + instead of sharing one `.bss` array. `CLASS_STATIC_ACCESSORS` leaves the + `per_test_global!` set (the per-image handle already keeps one libtest + thread's clear out of another's reach), and the GC test guards no longer + clear it — it holds code addresses, not roots. diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index aa1ffbc654..4e03ab1a57 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -345,6 +345,9 @@ pub struct UnitCodegenStats { pub rewrite_secs: f64, pub optimize_secs: f64, pub emit_secs: f64, + /// Functions stamped `"disable-tail-calls"` because their alloca-walk + /// estimate exceeded [`DEFAULT_TRE_MAX_ALLOCA_WALK`] (#8883). + pub tail_call_elim_skipped: Vec, } fn function_instruction_count(function: inkwell::values::FunctionValue<'_>) -> usize { @@ -642,6 +645,187 @@ fn pre_rewrite_sizes( sizes } +/// Per-function budget for TailCallElim's alloca-escape walk (#8883). +/// +/// `TailCallElimPass::markTails` starts a use-def walk at EVERY alloca (and +/// byval argument) and follows the transitive SSA uses: through call +/// results, phis, selects, casts, GEPs and arithmetic; only a `load` or +/// `store` ends a branch, and only a `nocapture` call argument. In a +/// statepoint-rewritten function an alloca handed to any runtime call (the +/// argument arrays Perry builds on the stack) reaches the statepoint token, +/// every `gc.relocate` hanging off it, and through their `gc-live` bundles +/// every later statepoint — so each walk covers close to the whole function +/// and the pass costs `allocas × uses`, not `uses`. The reported Next.js +/// route (jsonwebtoken's bundled entry, 400 allocas, 643k post-RS4GC +/// instructions, 3.4k statepoints with 477k relocates) held one LLVM worker +/// for ~100 CPU-minutes in that walk, on a unit the rest of `-Os` finishes +/// in ~20 s. +/// +/// The estimate is the product `allocas × instructions` of the function LLVM +/// is about to optimize — an upper bound on the walk that costs one linear +/// pass to compute. A function over the cap is stamped +/// `"disable-tail-calls"="true"`, which is the switch TRE itself honours +/// (`eliminateTailRecursion` returns before `markTails`). #8421's contract +/// — every function optimized at the requested level — is kept for every +/// other pass: the function still goes through the full `default` +/// pipeline. What it gives up is exactly what the attribute names: tail +/// recursion is not turned into a loop, and the backend does not emit calls +/// in return position as jumps (SelectionDAG's `canTailCall` and GlobalISel's +/// `CallLowering` both read the attribute; `musttail` is exempt and Perry +/// emits none). It is NOT `optnone` — #8583's RS4GC-root hazard does not +/// apply, because RS4GC has already run when the attribute is stamped and +/// nothing about GC roots changes. +/// +/// `PERRY_LL_TRE_MAX_ALLOCA_WALK=` raises or lowers the cap; `0`/`off` +/// disables the budget (every function keeps TRE, whatever it costs). +const DEFAULT_TRE_MAX_ALLOCA_WALK: u64 = 1 << 26; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TreWalkBudget { + Off, + Cap(u64), +} + +fn parse_tre_walk_budget(value: Option<&str>) -> TreWalkBudget { + match value.map(str::trim) { + None | Some("") => TreWalkBudget::Cap(DEFAULT_TRE_MAX_ALLOCA_WALK), + Some("0") | Some("off") | Some("false") => TreWalkBudget::Off, + Some(v) => match v.parse::() { + Ok(0) => TreWalkBudget::Off, + Ok(n) => TreWalkBudget::Cap(n), + Err(_) => TreWalkBudget::Cap(DEFAULT_TRE_MAX_ALLOCA_WALK), + }, + } +} + +fn tre_walk_budget() -> TreWalkBudget { + #[cfg(test)] + if let Some(budget) = TEST_TRE_WALK_BUDGET.with(std::cell::Cell::get) { + return budget; + } + parse_tre_walk_budget( + std::env::var("PERRY_LL_TRE_MAX_ALLOCA_WALK") + .ok() + .as_deref(), + ) +} + +#[cfg(test)] +thread_local! { + static TEST_TRE_WALK_BUDGET: std::cell::Cell> = const { + std::cell::Cell::new(None) + }; +} + +/// Thread-local budget seam for tests, for the same reason as +/// [`with_test_rs4gc_budget`]: mutating `PERRY_LL_TRE_MAX_ALLOCA_WALK` would +/// race every concurrently running LLVM test in the binary. +#[cfg(test)] +pub(crate) fn with_test_tre_walk_budget(cap: u64, run: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + TEST_TRE_WALK_BUDGET.with(|budget| budget.set(self.0)); + } + } + let old = TEST_TRE_WALK_BUDGET.replace(Some(TreWalkBudget::Cap(cap))); + let _restore = Restore(old); + run() +} + +/// The function attribute TailCallElim and the backends' tail-call lowering +/// both read. Stamped by [`disable_tail_call_elim_over_budget`]. +const DISABLE_TAIL_CALLS_ATTR: &str = "disable-tail-calls"; + +/// One function whose alloca-walk estimate exceeded the budget. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreWalkOverBudget { + pub name: String, + pub allocas: usize, + pub instructions: usize, + pub cap: u64, +} + +impl TreWalkOverBudget { + fn estimate(&self) -> u64 { + self.allocas as u64 * self.instructions as u64 + } +} + +impl std::fmt::Display for TreWalkOverBudget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "`{}` has {} allocas across {} instructions (alloca-walk estimate {}, budget {}); \ + skipping tail-call elimination for it, because TailCallElim's alloca-escape walk \ + is quadratic in exactly that product on a statepoint-rewritten body (#8883). Every \ + other pass still runs at the requested level; the function only loses \ + tail-recursion-to-loop and sibling-call codegen. Override with \ + PERRY_LL_TRE_MAX_ALLOCA_WALK= (raise) or =0 (disable).", + self.name, + self.allocas, + self.instructions, + self.estimate(), + self.cap + ) + } +} + +/// `(allocas, instructions)` of one defined function — the two factors of +/// the walk estimate, from the same linear pass `function_instruction_count` +/// makes. +fn alloca_walk_factors(function: inkwell::values::FunctionValue<'_>) -> (usize, usize) { + let mut allocas = 0usize; + let mut instrs = 0usize; + for bb in function.get_basic_blocks() { + let mut inst = bb.get_first_instruction(); + while let Some(i) = inst { + instrs += 1; + if i.get_opcode() == inkwell::values::InstructionOpcode::Alloca { + allocas += 1; + } + inst = i.get_next_instruction(); + } + } + (allocas, instrs) +} + +/// Stamp `"disable-tail-calls"="true"` on every defined function whose +/// `allocas × instructions` exceeds `budget`, and return what was stamped +/// so the caller can say so. Runs on the module exactly as the optimization +/// pipeline will see it (after RS4GC under native roots). +fn disable_tail_call_elim_over_budget<'ctx>( + module: &inkwell::module::Module<'ctx>, + budget: TreWalkBudget, +) -> Vec { + let cap = match budget { + TreWalkBudget::Off => return Vec::new(), + TreWalkBudget::Cap(cap) => cap, + }; + let context = module.get_context(); + let mut over = Vec::new(); + let mut function = module.get_first_function(); + while let Some(f) = function { + if f.count_basic_blocks() > 0 { + let (allocas, instructions) = alloca_walk_factors(f); + if allocas as u64 * instructions as u64 > cap { + f.add_attribute( + inkwell::attributes::AttributeLoc::Function, + context.create_string_attribute(DISABLE_TAIL_CALLS_ATTR, "true"), + ); + over.push(TreWalkOverBudget { + name: f.get_name().to_string_lossy().into_owned(), + allocas, + instructions, + cap, + }); + } + } + function = f.get_next_function(); + } + over +} + fn optimize_and_emit( module: &inkwell::module::Module<'_>, effective_target: &str, @@ -781,6 +965,18 @@ fn optimize_and_emit( 'z' => "default", _ => "default", }; + // TailCallElim runs inside every `default` function-simplification + // pipeline; bound its alloca walk on the module the pipeline will see + // (#8883). `-O0` runs no TRE, so there is nothing to bound. + if opt != '0' { + let skipped = disable_tail_call_elim_over_budget(module, tre_walk_budget()); + for over in &skipped { + eprintln!("perry: {over}"); + } + if let Some(stats) = stats.as_deref_mut() { + stats.tail_call_elim_skipped = skipped; + } + } let optimize_started = std::time::Instant::now(); module .run_passes(pipeline, &tm, PassBuilderOptions::create()) @@ -1475,4 +1671,219 @@ entry: being ignored somewhere in the emission path" ); } + #[test] + fn tre_walk_budget_spellings() { + assert_eq!( + parse_tre_walk_budget(None), + TreWalkBudget::Cap(DEFAULT_TRE_MAX_ALLOCA_WALK) + ); + assert_eq!( + parse_tre_walk_budget(Some("")), + TreWalkBudget::Cap(DEFAULT_TRE_MAX_ALLOCA_WALK) + ); + assert_eq!(parse_tre_walk_budget(Some("0")), TreWalkBudget::Off); + assert_eq!(parse_tre_walk_budget(Some("off")), TreWalkBudget::Off); + assert_eq!(parse_tre_walk_budget(Some("false")), TreWalkBudget::Off); + assert_eq!( + parse_tre_walk_budget(Some(" 250000 ")), + TreWalkBudget::Cap(250_000) + ); + assert_eq!( + parse_tre_walk_budget(Some("lots")), + TreWalkBudget::Cap(DEFAULT_TRE_MAX_ALLOCA_WALK) + ); + } + + /// Two functions: `wide` has 4 allocas across 9 instructions (estimate + /// 36), `narrow` has one across 3 (estimate 3), and `decl` has no body. + fn alloca_walk_fixture() -> &'static str { + r#" +declare void @sink(ptr) + +define void @wide() { +entry: + %a = alloca i64 + %b = alloca i64 + %c = alloca i64 + %d = alloca i64 + call void @sink(ptr %a) + call void @sink(ptr %b) + call void @sink(ptr %c) + call void @sink(ptr %d) + ret void +} + +define void @narrow() { +entry: + %a = alloca i64 + call void @sink(ptr %a) + ret void +} +"# + } + + fn has_disable_tail_calls(module: &inkwell::module::Module<'_>, name: &str) -> bool { + module + .get_function(name) + .expect("fixture function exists") + .get_string_attribute( + inkwell::attributes::AttributeLoc::Function, + DISABLE_TAIL_CALLS_ATTR, + ) + .is_some_and(|attr| attr.get_string_value().to_bytes() == b"true") + } + + /// The budget is `allocas × instructions`, applied per function: only + /// the function over it is stamped, the boundary is exclusive, and + /// `off` stamps nothing. + #[test] + fn tre_budget_stamps_only_the_function_over_it() { + let context = Context::create(); + let module = parse_ir_text(&context, alloca_walk_fixture(), "tre_budget_fixture") + .expect("fixture parses"); + let wide = module.get_function("wide").expect("wide"); + let narrow = module.get_function("narrow").expect("narrow"); + assert_eq!(alloca_walk_factors(wide), (4, 9)); + assert_eq!(alloca_walk_factors(narrow), (1, 3)); + + assert!( + disable_tail_call_elim_over_budget(&module, TreWalkBudget::Off).is_empty(), + "a disabled budget stamps nothing" + ); + assert!(!has_disable_tail_calls(&module, "wide")); + + let exact = disable_tail_call_elim_over_budget(&module, TreWalkBudget::Cap(36)); + assert!(exact.is_empty(), "the cap is inclusive: {exact:?}"); + + let over = disable_tail_call_elim_over_budget(&module, TreWalkBudget::Cap(35)); + assert_eq!( + over, + vec![TreWalkOverBudget { + name: "wide".to_string(), + allocas: 4, + instructions: 9, + cap: 35, + }] + ); + assert!(has_disable_tail_calls(&module, "wide")); + assert!(!has_disable_tail_calls(&module, "narrow")); + let message = over[0].to_string(); + for needle in [ + "`wide`", + "4 allocas", + "9 instructions", + "estimate 36", + "budget 35", + "PERRY_LL_TRE_MAX_ALLOCA_WALK", + "#8883", + ] { + assert!( + message.contains(needle), + "{needle} missing from:\n{message}" + ); + } + assert!( + !message.contains("optnone"), + "the budget must never read as a demotion:\n{message}" + ); + } + + /// A self-recursive tail call that TailCallElim turns into a loop at + /// the pinned LLVM: with no attribute the recursive `call` disappears, + /// with `"disable-tail-calls"="true"` (exactly what the budget stamps) + /// it survives the full `default` pipeline — so the lever the + /// budget pulls is live, not merely spelled. + fn tail_recursive_fixture(attrs: &str) -> String { + format!( + "define i64 @count_down(i64 %n, i64 %acc) noinline {attrs} {{\n\ + entry:\n\ + \x20 %done = icmp eq i64 %n, 0\n\ + \x20 br i1 %done, label %ret, label %rec\n\ + rec:\n\ + \x20 %n1 = sub i64 %n, 1\n\ + \x20 %acc1 = add i64 %acc, %n\n\ + \x20 %r = call i64 @count_down(i64 %n1, i64 %acc1)\n\ + \x20 ret i64 %r\n\ + ret:\n\ + \x20 ret i64 %acc\n\ + }}\n" + ) + } + + #[test] + fn disable_tail_calls_attribute_stops_tail_call_elim_at_the_pinned_llvm() { + let target = crate::codegen::default_target_triple(); + let with_tre = statepoint_rewritten_ir_with_passes( + &tail_recursive_fixture(""), + &target, + "tre_control", + "default", + ) + .expect("control optimizes"); + assert!( + !with_tre.contains("call i64 @count_down"), + "control: TailCallElim must turn the tail recursion into a loop, or this test \ + cannot tell the attribute apart from a no-op:\n{with_tre}" + ); + + let without_tre = statepoint_rewritten_ir_with_passes( + &tail_recursive_fixture(&format!("\"{DISABLE_TAIL_CALLS_ATTR}\"=\"true\"")), + &target, + "tre_disabled", + "default", + ) + .expect("attributed fixture optimizes"); + assert!( + without_tre.contains("call i64 @count_down"), + "the attribute must keep TailCallElim off the function:\n{without_tre}" + ); + } + + /// The budget is wired into the shipped emission path: under a cap of + /// zero every function with an alloca is stamped before `default` + /// runs, the per-unit stats name it, and the unit still emits. + #[test] + fn tre_budget_is_applied_by_the_shipped_pipeline() { + global_init(&[]); + let target = crate::codegen::default_target_triple(); + let context = Context::create(); + let module = parse_ir_text(&context, alloca_walk_fixture(), "tre_budget_shipped") + .expect("fixture parses"); + let mut stats = UnitCodegenStats::default(); + let object = with_test_tre_walk_budget(0, || { + optimize_and_emit_module_with_stats( + &module, + &target, + &["-Os".into(), "-c".into()], + false, + Some(&mut stats), + ) + }) + .expect("a stamped module still optimizes and emits"); + assert!(!object.is_empty()); + let mut names: Vec<&str> = stats + .tail_call_elim_skipped + .iter() + .map(|over| over.name.as_str()) + .collect(); + names.sort_unstable(); + assert_eq!(names, ["narrow", "wide"]); + + // -O0 runs no TailCallElim, so nothing is stamped there. + let module = parse_ir_text(&context, alloca_walk_fixture(), "tre_budget_o0") + .expect("fixture parses"); + let mut stats = UnitCodegenStats::default(); + with_test_tre_walk_budget(0, || { + optimize_and_emit_module_with_stats( + &module, + &target, + &["-O0".into(), "-c".into()], + false, + Some(&mut stats), + ) + }) + .expect("-O0 emits"); + assert!(stats.tail_call_elim_skipped.is_empty()); + assert!(!has_disable_tail_calls(&module, "wide")); + } } diff --git a/crates/perry-hir/Cargo.toml b/crates/perry-hir/Cargo.toml index 08f72b1e49..b505dab798 100644 --- a/crates/perry-hir/Cargo.toml +++ b/crates/perry-hir/Cargo.toml @@ -20,6 +20,7 @@ perry-ui-model.workspace = true perry-parser.workspace = true swc_ecma_ast.workspace = true swc_common.workspace = true +swc_ecma_visit.workspace = true thiserror.workspace = true anyhow.workspace = true diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 9c1d6d559e..e55402add8 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -205,6 +205,7 @@ impl LoweringContext { class_renames: std::collections::HashMap::new(), next_class_rename_id: 0, module_class_decl_names: std::collections::HashSet::new(), + class_decl_names_any_depth: std::collections::HashSet::new(), next_anon_shape_id: 0, class_method_return_types: Vec::new(), class_captures: Vec::new(), diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 65c5014dc5..8f361db3b3 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -1572,6 +1572,24 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // (`new Missing()`), distinct from the TypeError produced when a // present binding's value is non-constructable. // + // #8882: the failure must be decided when the `new` EXECUTES, not + // here. Two shapes the lowering-time lookups cannot see: (1) a + // class declared in a function body that is lowered LATER — the + // CJS wrap leaves some top-level classes inside the module IIFE + // while hoisting their siblings, so a hoisted `LRUCache` + // constructor's `new SentinelNode()` (Next's `lru-cache.js`) is + // lowered before the `__perry_cjs_factory` body registers + // `SentinelNode`; (2) a global that exists only at runtime + // (`typeof IntersectionObserver === "function" && new + // IntersectionObserver(…)`). The #8643 guard lowered both to an + // unconditional, NAMELESS `ReferenceError: identifier is not + // defined`, which killed the whole application at init. Now a + // name declared as a class anywhere in the module keeps the + // late-bound by-name `Expr::New` below (codegen resolves it + // through the module class table, as before #8643), and any other + // name is read off `globalThis` when the `new` runs, throwing the + // spec `ReferenceError: is not defined` on a true miss. + // // Consult the native-module registry under BOTH the (possibly // rewritten) `class_name` AND the original `source_class_name`. // The alias-rewrite block just above replaces `class_name` with a @@ -1592,11 +1610,20 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R && ctx.lookup_native_module(&class_name).is_none() && ctx.lookup_native_module(source_class_name).is_none() && !ctx.forward_class_names.contains(source_class_name) + && !ctx.class_decl_names_any_depth.contains(source_class_name) && !is_reified_global_builtin_constructor(&class_name) { + // Same wording as the bare-identifier arm so one grep over the + // compile log lists every name that will be resolved at + // runtime — #8882 could not be attributed from the log because + // the `new` path never said which identifier it gave up on. + eprintln!( + " Warning: unknown identifier '{source_class_name}' — assuming global; `new {source_class_name}()` resolves it by name on globalThis at runtime (ReferenceError on a miss)" + ); return Ok(Expr::NewDynamic { - callee: Box::new(super::throw_reference_error_expr( - "js_throw_reference_error_unresolved_get", + callee: Box::new(super::unresolved_global_get_expr( + source_class_name.to_string(), + new_byte_offset, )), args, byte_offset: new_byte_offset, diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index b2bbdf6ad8..76acf0aa96 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -42,8 +42,8 @@ pub(crate) use helpers::{ global_script_this_enabled, is_fetch_global_value_name, is_known_global_identifier_name, lower_expr_with_json_parse_type_hint, native_module_binding_value, opt_call_func_nullish_guard, opt_call_receiver_repeatable, relower_trace, strict_global_assign_existing_or_throw, - throw_reference_error_expr, with_implicit_unset_let, with_set_fallback_for_ident, - wrap_with_gets, + throw_reference_error_expr, unresolved_global_get_expr, with_implicit_unset_let, + with_set_fallback_for_ident, wrap_with_gets, }; pub(crate) use reactive_text::try_desugar_reactive_text; diff --git a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs index 6dcec4ec7f..1d0819a152 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs @@ -288,18 +288,12 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) -> name ); } - return Ok(Expr::Call { - callee: Box::new(Expr::ExternFuncRef { - name: "js_global_get_or_throw_unresolved".to_string(), - param_types: vec![Type::Any], - return_type: Type::Any, - }), - args: vec![Expr::String(name.clone())], - type_args: Vec::new(), - // #5253: localize the `X is not defined` ReferenceError to - // this identifier's source position (winston `module`). - byte_offset: ident.span.lo.0, - }); + // #5253: localize the `X is not defined` ReferenceError to + // this identifier's source position (winston `module`). + return Ok(super::helpers::unresolved_global_get_expr( + name.clone(), + ident.span.lo.0, + )); } // Bare built-in constructor identifiers (`Date`, `Array`, // `Object`, ...) used as VALUES (not method receivers / diff --git a/crates/perry-hir/src/lower/lower_expr/helpers.rs b/crates/perry-hir/src/lower/lower_expr/helpers.rs index 5a95f9ef15..db8b60f754 100644 --- a/crates/perry-hir/src/lower/lower_expr/helpers.rs +++ b/crates/perry-hir/src/lower/lower_expr/helpers.rs @@ -44,6 +44,29 @@ pub(crate) fn throw_reference_error_expr(helper_name: &str) -> Expr { } } +/// Read a compile-time-unresolved identifier off `globalThis` at runtime, +/// throwing the spec `ReferenceError: is not defined` when no such +/// global exists (`js_global_get_or_throw_unresolved`). A global created at +/// RUNTIME (`Function("this.y = 2")()`, a `typeof IntersectionObserver === +/// "function"`-guarded browser API) is invisible to compile-time resolution, +/// so the miss must be decided when the read executes — and the message +/// must carry the identifier (#8730, #8882). `byte_offset` localizes the +/// error to the identifier's source position (#5253). Shared by the bare +/// identifier arm (`arm_ident.rs`) and `lower_new`'s unresolved-constructor +/// fallback so the two cannot drift. +pub(crate) fn unresolved_global_get_expr(name: String, byte_offset: u32) -> Expr { + Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_global_get_or_throw_unresolved".to_string(), + param_types: vec![Type::Any], + return_type: Type::Any, + }), + args: vec![Expr::String(name)], + type_args: Vec::new(), + byte_offset, + } +} + /// #5989: lower a strict-mode assignment to an identifier with no lexical /// binding. Per spec (PutValue on a reference that resolves to the global /// environment), an EXISTING global property is a normal property write — diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index c7a1c4fd82..718e1d30a5 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -908,6 +908,10 @@ pub fn lower_module_full( // literals, and counter vars (see `fn_ctor_env`). ctx.fn_ctor_env = super::fn_ctor_env::build_fn_ctor_env(ast_module); + // #8882: every class DECLARATION name at any depth, for `lower_new`'s + // unresolved-constructor guard (see `pre_scan/class_decl_names.rs`). + pre_scan_class_decl_names(ast_module, &mut ctx); + // Pre-scan for WeakRef/FinalizationRegistry variable declarations so subsequent // method-call lowering (`x.deref()`, `x.register(...)`, `x.unregister(...)`) can // route via the dedicated HIR variants without relying on type inference. diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 8edf6ba83e..f2da7d7f38 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -816,6 +816,15 @@ pub struct LoweringContext { /// `lower_class_from_ast` detect that collision and allocate a fresh, /// uniquely-named class instead. pub(crate) module_class_decl_names: std::collections::HashSet, + /// #8882: names of `class X { … }` DECLARATIONS anywhere in the module, + /// at any nesting depth (populated by `pre_scan_class_decl_names`). + /// Consulted by `lower_new`'s unresolved-constructor guard: a name that + /// is declared as a class somewhere in the module — typically inside a + /// function body lowered later, such as the CJS wrap's IIFE — keeps the + /// late-bound by-name construction instead of a compile-time + /// `ReferenceError`. Unlike `module_class_decl_names` this is NOT limited + /// to top level and is never used for ClassId (re)allocation. + pub(crate) class_decl_names_any_depth: std::collections::HashSet, /// Counter for generating anon-class names (`__AnonShape_N`). // #854: initialized in `new` but unread — anon-shape classes are now named // by content-addressed FNV hash (see `synthesize_anon_shape_class`), not by diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index d6956269a5..6ce65947ac 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -116,8 +116,8 @@ pub use lower_module_fn::{ mod lower_expr; pub(crate) use lower_expr::{ lower_expr, lower_expr_assignment, strict_global_assign_existing_or_throw, - throw_reference_error_expr, try_desugar_reactive_text, with_implicit_unset_let, - with_set_fallback_for_ident, + throw_reference_error_expr, try_desugar_reactive_text, unresolved_global_get_expr, + with_implicit_unset_let, with_set_fallback_for_ident, }; // Re-export extracted module functions diff --git a/crates/perry-hir/src/lower/pre_scan.rs b/crates/perry-hir/src/lower/pre_scan.rs index fc3335e171..ff378daba8 100644 --- a/crates/perry-hir/src/lower/pre_scan.rs +++ b/crates/perry-hir/src/lower/pre_scan.rs @@ -10,8 +10,10 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; +mod class_decl_names; mod weakref_locals; +pub(crate) use class_decl_names::pre_scan_class_decl_names; pub(crate) use weakref_locals::pre_scan_weakref_locals; /// Pre-scan top-level function declarations for the standard TypeScript diff --git a/crates/perry-hir/src/lower/pre_scan/class_decl_names.rs b/crates/perry-hir/src/lower/pre_scan/class_decl_names.rs new file mode 100644 index 0000000000..a61637558d --- /dev/null +++ b/crates/perry-hir/src/lower/pre_scan/class_decl_names.rs @@ -0,0 +1,44 @@ +//! Pre-scan for every `class X { … }` DECLARATION name in the module, at any +//! nesting depth. +//! +//! #8882: `lower_new`'s unresolved-constructor guard (#8643) decides at +//! lowering time whether `new X()` can bind at all. Its lookups only see +//! bindings registered so far, but a class declared inside a function body +//! that is lowered LATER is still a legitimate late-bound target — JS +//! resolves the constructor reference when the `new` executes, not when the +//! enclosing method is compiled. The CJS wrap makes this shape common: it +//! hoists most top-level classes out of the module IIFE but leaves some +//! inside (a class it did not recognise textually, or one that reads an +//! IIFE-local), so a hoisted class's constructor can `new` a sibling that is +//! now nested in the `__perry_cjs_factory` closure and registered only when +//! that closure body is lowered. Next's `server/lib/lru-cache.js` has exactly +//! this: `LRUCache` (hoisted) constructs `SentinelNode` (left in the IIFE +//! because its doc comment closes on the `class` line). +//! +//! The guard consults this set: a name declared as a class anywhere in the +//! module keeps the by-name `Expr::New` lowering that codegen resolves through +//! the module class table (the pre-#8643 behaviour); anything else is a +//! runtime `globalThis` lookup that throws the spec `ReferenceError: X is not +//! defined`. Only DECLARATIONS count — a named class EXPRESSION's name binds +//! inside its own body alone. + +use swc_ecma_ast as ast; +use swc_ecma_visit::{Visit, VisitWith}; + +use crate::lower::*; + +pub(crate) fn pre_scan_class_decl_names(ast_module: &ast::Module, ctx: &mut LoweringContext) { + struct Collector<'a> { + names: &'a mut std::collections::HashSet, + } + impl Visit for Collector<'_> { + fn visit_class_decl(&mut self, class_decl: &ast::ClassDecl) { + self.names.insert(class_decl.ident.sym.to_string()); + class_decl.visit_children_with(self); + } + } + let mut collector = Collector { + names: &mut ctx.class_decl_names_any_depth, + }; + ast_module.visit_with(&mut collector); +} diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 3ac5e84935..89f113ae34 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1525,3 +1525,89 @@ fn typescript_transpile_subset_lowers_to_native_dispatch_and_enums() { "diagnostic flattening must use TypeScript native dispatch: {dump}" ); } + +/// #8882: a module-level class constructing a sibling class that is declared +/// inside a function body lowered LATER. This is the shape the CJS wrap +/// produces for Next's `server/lib/lru-cache.js`: `LRUCache` is hoisted out of +/// the module IIFE while `SentinelNode` (whose doc comment closes on the +/// `class` line, so the textual hoister never sees it) stays inside the +/// `__perry_cjs_factory` closure. JS binds the constructor reference when the +/// `new` executes; the #8643 guard instead lowered it to an unconditional, +/// nameless `ReferenceError` that killed the application at init. +#[test] +fn hoisted_class_constructs_sibling_declared_inside_a_later_closure() { + let source = r#" + class LRUCache { + constructor() { + this.head = new SentinelNode(); + this.tail = new SentinelNode(); + } + } + const _cjs = (function () { + class SentinelNode { + constructor() { + this.prev = null; + this.next = null; + } + } + return { SentinelNode }; + })(); + "#; + let module = perry_parser::parse_typescript(source, "lru-cache.js").expect("source parses"); + let hir = super::lower_module(&module, "lru-cache", "lru-cache.js").expect("source lowers"); + let lru_cache = hir + .classes + .iter() + .find(|class| class.name == "LRUCache") + .expect("LRUCache class is lowered"); + let debug = format!("{lru_cache:?}"); + + assert!( + !debug.contains("js_throw_reference_error_unresolved_get") + && !debug.contains("js_global_get_or_throw_unresolved"), + "a sibling class declared later in the module must not lower to a \ + compile-time ReferenceError:\n{debug}" + ); + assert_eq!( + debug.matches(r#"New { class_name: "SentinelNode""#).count(), + 2, + "both `new SentinelNode()` sites must stay late-bound by-name constructs:\n{debug}" + ); +} + +/// #8882 / #8730: a constructor name that resolves to nothing in the module +/// is read off `globalThis` when the `new` executes — exactly like a bare +/// identifier read — so a runtime-created global constructs and a true miss +/// throws `ReferenceError: is not defined` WITH the identifier. The +/// `typeof`-guarded browser-API shape is the one Next's `app-page` runtime +/// carries; it previously lowered to the nameless throw even though the guard +/// makes the branch dead on a server. +#[test] +fn unresolved_new_names_the_identifier_and_defers_to_a_runtime_global_lookup() { + let source = r#" + function observe(cb: any): any { + return typeof IntersectionObserver === "function" + ? new IntersectionObserver(cb) + : null; + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let observe = hir + .functions + .iter() + .find(|function| function.name == "observe") + .expect("observe is lowered"); + let debug = format!("{observe:?}"); + + assert!( + !debug.contains("js_throw_reference_error_unresolved_get"), + "the nameless ReferenceError helper must not be emitted for `new ()`:\n{debug}" + ); + assert!( + debug.contains( + r#"NewDynamic { callee: Call { callee: ExternFuncRef { name: "js_global_get_or_throw_unresolved", param_types: [Any], return_type: Any }, args: [String("IntersectionObserver")]"# + ), + "an unresolved constructor must be a runtime globalThis lookup carrying its name:\n{debug}" + ); +} diff --git a/crates/perry-hir/tests/aliased_native_new_resolution.rs b/crates/perry-hir/tests/aliased_native_new_resolution.rs index 1705e79353..fe6e879533 100644 --- a/crates/perry-hir/tests/aliased_native_new_resolution.rs +++ b/crates/perry-hir/tests/aliased_native_new_resolution.rs @@ -17,6 +17,7 @@ use perry_hir::lower_module; use perry_parser::parse_typescript_with_cache; const THROW_HELPER: &str = "js_throw_reference_error_unresolved_get"; +const GLOBAL_LOOKUP_HELPER: &str = "js_global_get_or_throw_unresolved"; fn lower_debug(src: &str) -> String { let src = src.to_string(); @@ -92,12 +93,21 @@ fn unaliased_native_class_import_still_constructs() { fn genuinely_unresolved_new_still_throws() { // Positive control: the guard must still fire for a `new` on an identifier // that resolves to no binding at all — the fix must not blanket-suppress it. + // #8882: it now defers to a runtime `globalThis` lookup that carries the + // identifier (`ReferenceError: Totally_Undefined_Constructor_Xyz is not + // defined` on a miss) instead of the nameless throw. let debug = lower_debug(r#"const x = new Totally_Undefined_Constructor_Xyz();"#); assert!( - debug.contains(THROW_HELPER), - "a genuinely unresolved `new` must still lower to the nameless \ + !debug.contains(THROW_HELPER), + "a genuinely unresolved `new` must no longer lower to the nameless \ ReferenceError throw:\n{debug}" ); + assert!( + debug.contains(GLOBAL_LOOKUP_HELPER) + && debug.contains("\"Totally_Undefined_Constructor_Xyz\""), + "a genuinely unresolved `new` must lower to a named runtime global \ + lookup:\n{debug}" + ); } #[test] diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 371bd18ace..b862410264 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1126,6 +1126,12 @@ pub fn gc_init() { #[no_mangle] pub extern "C" fn js_gc_init() { + // #8546: this is the first runtime call of every `main` / `perry_module_init`, + // on the thread about to run that image's module init — so it is where the + // thread claims its own class-registry image before any `js_register_class_*` + // call lands. A host that loads several application images on several + // threads gets one image per thread; a plain executable gets one. + crate::object::class_image::enter_current_thread_image(); // Parse LLVM stack-map metadata before the first collection. The parser // allocates its immutable index once; root scans themselves must remain // allocation-free while the collector owns the heap. diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 15209c5fdc..adb2b5b60a 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -6,6 +6,7 @@ //! entry point, and the replay helper invoked by the heap-class-object arm of //! `js_new_function_construct`. +use crate::object::class_image::{ConstructorFlagTable, ConstructorTable, ImageTable}; use std::collections::HashMap; use std::sync::RwLock; @@ -86,7 +87,8 @@ pub extern "C" fn js_class_capture_value_for_receiver( /// Top-level class DECLARATIONS keep the INT32 class-ref `new` path and do not /// consult this table, so registering every class's constructor is /// behavior-neutral for them. -pub static CLASS_CONSTRUCTORS: RwLock>> = RwLock::new(None); +pub static CLASS_CONSTRUCTORS: ImageTable>> = + ImageTable::new(|image| &image.constructors); /// #1787: register a class's standalone constructor in `CLASS_CONSTRUCTORS`, /// keyed by the (template) class_id, so `new ()` can replay @@ -140,7 +142,8 @@ fn lookup_class_constructor(class_id: u32) -> Option<(usize, u32, u32)> { /// the `super(...spread)` apply path (`js_super_construct_apply`) can forward /// the flat spread args and let `call_vtable_method` pack the trailing slot /// correctly. Absent entry ⇒ neither flag (a plain fixed-arity ctor). -static CLASS_CONSTRUCTOR_FLAGS: RwLock>> = RwLock::new(None); +static CLASS_CONSTRUCTOR_FLAGS: ImageTable>> = + ImageTable::new(|image| &image.constructor_flags); /// Codegen FFI: record `(has_synthetic_arguments, has_rest)` for a class ctor. /// See [`CLASS_CONSTRUCTOR_FLAGS`]. diff --git a/crates/perry-runtime/src/object/class_image.rs b/crates/perry-runtime/src/object/class_image.rs new file mode 100644 index 0000000000..3301c39616 --- /dev/null +++ b/crates/perry-runtime/src/object/class_image.rs @@ -0,0 +1,462 @@ +//! Per-image class registries (#8546). +//! +//! Every class-id-keyed table that codegen populates at module init — +//! vtables, static methods and accessors, constructors, parent edges, names, +//! `.length`s, the `extends Error` / `DataView` / typed-array marks, the +//! `Symbol.hasInstance` / `Symbol.toStringTag` hooks — used to be a +//! process-global `static`. Class ids are assigned by codegen from a small +//! sequential counter, so they identify a class *within one compiled image* +//! and nothing else. A host that dlopens several application images into one +//! process (Coop hosts each deployment on its own dedicated Perry thread) has N +//! images registering the SAME ids with DIFFERENT `func_ptr`s — each image's +//! own code addresses — into one table. `HashMap::insert` is last-writer-wins, +//! so after the last image's init every class of every earlier image +//! dispatched into the last image's code, and only the last-initialised +//! application worked. +//! +//! No write order over a shared table can work: first-wins for methods only +//! leaves every vtable a mix of two images; first-owner for every entry point +//! leaves later images unable to initialise at all (they both write and read +//! these tables during their own init). The tables have to be per image. +//! +//! # The model +//! +//! An **image** is one compiled program's worth of class metadata, +//! [`ClassImageTables`]. A thread resolves its image in this order: +//! +//! 1. the image installed in its own thread-local slot, if any; +//! 2. otherwise the process's **primary** image — the first image ever +//! created in the process. +//! +//! Installation happens at exactly three points: +//! +//! * [`enter_current_thread_image`], called from `js_gc_init`, which codegen +//! emits as the first runtime call of both an executable's `main` and a +//! library's `perry_module_init` — i.e. on whichever thread runs an image's +//! module init, before any class is registered. The first thread to enter +//! creates the primary image and owns it; every later thread that enters +//! gets a fresh, private image. A host loading three applications on three +//! threads therefore gets three images, and a plain executable gets one. +//! Idempotent per thread. +//! * [`adopt_image`], on a `perry/thread` worker (`spawn`, `parallelMap`, +//! `parallelFilter`) and a `worker_threads` Worker, with the handle its +//! spawner captured via [`current_image_handle`]. Those threads never run +//! module init (the closure body is all they execute — see `thread.rs`), so +//! they must SHARE their spawner's tables rather than start empty. +//! * Nothing else. A thread that neither entered nor adopted — a pump running +//! JS on the primary heap's behalf (Android's UI thread firing timers via +//! `nativePumpTick`), a reactor thread, a libtest thread — reads and writes +//! the primary image, which is exactly the process-global table it saw +//! before this module existed. A program with one image is behaviourally +//! unchanged. +//! +//! Why not key by `AgentId` or by thread: `CURRENT_AGENT` defaults to +//! `PRIMARY_AGENT` and a host's app thread is a plain `std::thread::spawn` +//! that never claims an agent, so agent-keying hands every hosted app one +//! table (#8528 hit the same wall). Pure thread-keying breaks the pump threads +//! and the `perry/thread` workers above, which run JS that dispatches through +//! these tables without ever running init. +//! +//! # Call sites +//! +//! Each table is a `static` [`ImageTable`] handle whose `read()` / `write()` +//! resolve the calling thread's image and lock that image's `RwLock` — the +//! same guard types the process-global statics handed out, so the ~100 use +//! sites are unchanged. The guards are `!Send`, so a reference into an image +//! cannot leave the thread that resolved it (see [`current`]). +//! +//! The `RegistryLatch`es that gate the slow paths (`HAS_INSTANCE_LATCH`, +//! `GENERIC_ORIGIN_LATCH`, …) and `VTABLE_GEN` stay process-global on +//! purpose: a latch armed by ANY image only ever costs another image the slow +//! path, never a wrong answer. + +use std::cell::OnceCell; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, LockResult, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use std::thread::ThreadId; + +use super::class_registry::ClassVTable; + +/// Number of class ids covered by the dense parent table (`parent_dense`). +/// See `object/class_meta_registry.rs` for why the hot parent-edge read is an +/// indexed load rather than a locked hash probe. +pub(crate) const PARENT_DENSE_CAP: usize = 1 << 16; + +/// class_id -> { name -> (func_ptr, param_count, has_rest) } for static methods. +pub type StaticMethodTable = HashMap>; +/// class_id -> { name -> (getter func_ptr, setter func_ptr) } for static accessors. +pub type StaticAccessorTable = HashMap>; +/// class_id -> (ctor func_ptr, total param count, signature capture count). +pub type ConstructorTable = HashMap; +/// class_id -> (has_synthetic_arguments, has_rest) for a registered constructor. +pub type ConstructorFlagTable = HashMap; + +/// One compiled image's class metadata: every class-id-keyed table module init +/// writes. Field docs live on the `static` handles that select them. +pub struct ClassImageTables { + pub(crate) vtables: RwLock>>, + pub(crate) static_methods: RwLock>, + pub(crate) static_accessors: RwLock>, + pub(crate) method_bind_lengths: RwLock>>, + pub(crate) static_method_bind_lengths: RwLock>>, + pub(crate) registered_class_ids: RwLock>>, + pub(crate) parents: RwLock>>, + /// `parent + 1` for every registered edge whose child id is + /// `< PARENT_DENSE_CAP`; `0` means "no edge". Heap-allocated per image + /// (256 KiB) rather than `.bss`, because there is one per image now. + pub(crate) parent_dense: Box<[AtomicU32]>, + pub(crate) fetch_parent_kind: RwLock>>, + pub(crate) generic_origin: RwLock>>, + pub(crate) extends_error: RwLock>>, + pub(crate) has_instance: RwLock>>, + pub(crate) to_string_tag: RwLock>>, + pub(crate) constructors: RwLock>, + pub(crate) constructor_flags: RwLock>, + pub(crate) extends_data_view: RwLock>>, + pub(crate) extends_typed_array: RwLock>>, + pub(crate) names: RwLock>>, + pub(crate) lengths: RwLock>>, + pub(crate) anon_shape_class_ids: RwLock>>, +} + +impl ClassImageTables { + fn new() -> Self { + Self { + vtables: RwLock::new(None), + static_methods: RwLock::new(None), + static_accessors: RwLock::new(None), + method_bind_lengths: RwLock::new(None), + static_method_bind_lengths: RwLock::new(None), + registered_class_ids: RwLock::new(None), + parents: RwLock::new(None), + parent_dense: (0..PARENT_DENSE_CAP).map(|_| AtomicU32::new(0)).collect(), + fetch_parent_kind: RwLock::new(None), + generic_origin: RwLock::new(None), + extends_error: RwLock::new(None), + has_instance: RwLock::new(None), + to_string_tag: RwLock::new(None), + constructors: RwLock::new(None), + constructor_flags: RwLock::new(None), + extends_data_view: RwLock::new(None), + extends_typed_array: RwLock::new(None), + names: RwLock::new(None), + lengths: RwLock::new(None), + anon_shape_class_ids: RwLock::new(None), + } + } +} + +/// An owning handle to one image's tables, for handing a spawner's image to +/// the thread it spawns ([`current_image_handle`] → [`adopt_image`]). Opaque: +/// the tables are only ever reached through the `static` [`ImageTable`] +/// handles on the thread that holds the image. +#[derive(Clone)] +pub struct ClassImageHandle(Arc); + +impl ClassImageHandle { + /// Identity of the image behind this handle — two handles compare equal + /// exactly when they share tables. For tests and diagnostics. + pub fn image_id(&self) -> usize { + Arc::as_ptr(&self.0) as usize + } +} + +/// The first image created in this process, and the thread that created it. +/// Never dropped: it is what every thread without an image of its own reads. +struct PrimaryImage { + tables: Arc, + owner: ThreadId, +} + +static PRIMARY_IMAGE: OnceLock = OnceLock::new(); + +crate::perry_thread_local! { + /// This thread's image, once installed by [`enter_current_thread_image`] + /// or [`adopt_image`]. Set at most once per thread — `current` hands out + /// references whose validity rests on the slot never being replaced. + static CURRENT_IMAGE: OnceCell> = OnceCell::new(); +} + +fn primary() -> &'static PrimaryImage { + PRIMARY_IMAGE.get_or_init(|| PrimaryImage { + tables: Arc::new(ClassImageTables::new()), + owner: std::thread::current().id(), + }) +} + +/// The calling thread's image: its own if one is installed, else the primary. +/// +/// The returned reference is valid for the life of the calling thread, not +/// for `'static`: this thread's `Arc` is set once, never replaced, and dropped +/// only in this thread's TLS teardown. Every value derived from it — the +/// `RwLock` guards [`ImageTable::read`] / [`ImageTable::write`] return — is +/// `!Send`, so nothing can carry it to a thread that outlives this one. During +/// TLS teardown `try_with` fails and the primary (never dropped) answers, so a +/// destructor that still consults class metadata reads a live table. +#[inline] +fn current() -> &'static ClassImageTables { + match CURRENT_IMAGE.try_with(|slot| slot.get().map(Arc::as_ptr)) { + // SAFETY: see the doc comment — the pointee is owned by this thread's + // `OnceCell>`, which is never replaced and outlives every + // (`!Send`) borrow taken from it on this thread. + Ok(Some(tables)) => unsafe { &*tables }, + _ => &primary().tables, + } +} + +/// Give the calling thread its own image, unless it already has one. +/// +/// Called from `js_gc_init`, i.e. at the top of every `main` / +/// `perry_module_init`, on the thread about to run that image's module init. +/// The first thread to enter creates and owns the primary image (so a thread +/// that touched class metadata before its `js_gc_init` — through the primary +/// fallback — keeps what it wrote); every later thread gets a fresh image. +pub fn enter_current_thread_image() { + let _ = CURRENT_IMAGE.try_with(|slot| { + if slot.get().is_some() { + return; + } + let me = std::thread::current().id(); + let primary = primary(); + let tables = if primary.owner == me { + Arc::clone(&primary.tables) + } else { + Arc::new(ClassImageTables::new()) + }; + let _ = slot.set(tables); + }); +} + +/// The image the calling thread resolves to, as a handle a spawned thread can +/// [`adopt_image`] before it runs any JS. +pub fn current_image_handle() -> ClassImageHandle { + let own = CURRENT_IMAGE + .try_with(|slot| slot.get().cloned()) + .ok() + .flatten(); + ClassImageHandle(own.unwrap_or_else(|| Arc::clone(&primary().tables))) +} + +/// Make the calling thread share `handle`'s tables. Must run before the +/// thread's first class-metadata access; a thread that already has an image +/// keeps it (so a `worker_threads` Worker that adopted its parent's image and +/// then re-runs module init through `js_gc_init` stays on the shared tables). +pub fn adopt_image(handle: ClassImageHandle) { + let _ = CURRENT_IMAGE.try_with(|slot| { + let _ = slot.set(handle.0); + }); +} + +/// Identity of the image the calling thread currently resolves to. +pub fn current_image_id() -> usize { + current() as *const ClassImageTables as usize +} + +/// A `static` handle selecting one table out of the calling thread's image. +/// +/// `read()` / `write()` return the plain `std::sync::RwLock` guards, so a call +/// site written against the former process-global `static RwLock<..>` compiles +/// unchanged. +pub struct ImageTable { + select: fn(&ClassImageTables) -> &T, +} + +impl ImageTable { + pub const fn new(select: fn(&ClassImageTables) -> &T) -> Self { + Self { select } + } +} + +impl ImageTable> { + /// Shared-lock this table in the calling thread's image. + #[inline] + pub fn read(&'static self) -> LockResult> { + (self.select)(current()).read() + } + + /// Exclusive-lock this table in the calling thread's image. + #[inline] + pub fn write(&'static self) -> LockResult> { + (self.select)(current()).write() + } +} + +/// One relaxed-ordering load from the calling image's dense parent table. +/// `idx` must be `< PARENT_DENSE_CAP`. +#[inline] +pub(crate) fn parent_dense_load(idx: usize) -> u32 { + current().parent_dense[idx].load(Ordering::Acquire) +} + +/// Publish one biased parent edge into the calling image's dense table. +/// `idx` must be `< PARENT_DENSE_CAP`. +#[inline] +pub(crate) fn parent_dense_store(idx: usize, biased_parent: u32) { + current().parent_dense[idx].store(biased_parent, Ordering::Release); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Barrier; + + /// Register `method` on `class_id` the way codegen's module-init prelude + /// does, with `func_ptr` standing in for the image's code address. + unsafe fn register_method(class_id: u32, method: &str, func_ptr: usize) { + crate::object::class_registry::js_register_class_method( + class_id as i64, + method.as_ptr(), + method.len() as i64, + func_ptr as i64, + 1, + 0, + 0, + ); + } + + /// The `func_ptr` dynamic dispatch would call for `class_id.method()`. + fn dispatch_target(class_id: u32, method: &str) -> Option { + crate::object::lookup_class_method_in_chain(class_id, method).map(|(ptr, ..)| ptr) + } + + /// #8546 — two application images register the SAME class id with + /// DIFFERENT method addresses, each on its own thread. Both registrations + /// complete before either thread looks up, so under a shared table the + /// last writer wins and one thread dispatches into the other image's + /// code. Each thread must see only its own image's `func_ptr`. + #[test] + fn two_application_threads_keep_their_own_class_vtables() { + const TWO_IMAGES_CLASS_ID: u32 = 0x7d01_8546; + const METHOD: &str = "m"; + let both_registered = Arc::new(Barrier::new(2)); + + let application = |func_ptr: usize, barrier: Arc| { + std::thread::spawn(move || { + // What `js_gc_init` does at the top of `perry_module_init`. + enter_current_thread_image(); + unsafe { register_method(TWO_IMAGES_CLASS_ID, METHOD, func_ptr) }; + barrier.wait(); + ( + current_image_id(), + dispatch_target(TWO_IMAGES_CLASS_ID, METHOD), + ) + }) + }; + + let a = application(0x1000, Arc::clone(&both_registered)); + let b = application(0x2000, both_registered); + let (a_image, a_target) = a.join().expect("application A panicked"); + let (b_image, b_target) = b.join().expect("application B panicked"); + + assert_eq!( + a_target, + Some(0x1000), + "application A dispatches `m` into the other image's code" + ); + assert_eq!( + b_target, + Some(0x2000), + "application B dispatches `m` into the other image's code" + ); + assert_ne!( + a_image, b_image, + "two entered application threads must hold distinct images" + ); + } + + /// A `perry/thread` worker never runs module init, so it must adopt its + /// spawner's image: the spawner's registrations are visible to it, and its + /// own registrations flow back — while a second application stays out of + /// reach of both. + #[test] + fn a_spawned_worker_shares_its_spawners_image() { + const SHARED_IMAGE_CLASS_ID: u32 = 0x7d02_8546; + let (spawner_sees, worker_sees, worker_image, spawner_image) = std::thread::spawn(|| { + enter_current_thread_image(); + unsafe { register_method(SHARED_IMAGE_CLASS_ID, "spawner", 0x11) }; + let handle = current_image_handle(); + let worker = std::thread::spawn(move || { + adopt_image(handle); + unsafe { register_method(SHARED_IMAGE_CLASS_ID, "worker", 0x22) }; + ( + dispatch_target(SHARED_IMAGE_CLASS_ID, "spawner"), + current_image_id(), + ) + }) + .join() + .expect("worker panicked"); + ( + dispatch_target(SHARED_IMAGE_CLASS_ID, "worker"), + worker.0, + worker.1, + current_image_id(), + ) + }) + .join() + .expect("spawner panicked"); + + assert_eq!( + worker_image, spawner_image, + "the worker adopted a different image" + ); + assert_eq!( + worker_sees, + Some(0x11), + "the worker cannot see its spawner's classes" + ); + assert_eq!( + spawner_sees, + Some(0x22), + "the spawner cannot see its worker's classes" + ); + + // And an unrelated application thread sees neither. + let other = std::thread::spawn(|| { + enter_current_thread_image(); + ( + dispatch_target(SHARED_IMAGE_CLASS_ID, "spawner"), + dispatch_target(SHARED_IMAGE_CLASS_ID, "worker"), + ) + }) + .join() + .expect("other application panicked"); + assert_eq!( + other, + (None, None), + "a second application sees the first's classes" + ); + } + + /// A thread that neither entered nor adopted an image — a pump thread + /// firing JS on the primary heap's behalf — reads the primary image, which + /// is where a thread that never called `js_gc_init` also writes. This is + /// the pre-#8546 process-global behaviour, kept for single-image programs. + #[test] + fn a_thread_without_an_image_uses_the_primary() { + const PRIMARY_IMAGE_CLASS_ID: u32 = 0x7d03_8546; + // The libtest thread has not entered an image; its write lands in the + // primary. + unsafe { register_method(PRIMARY_IMAGE_CLASS_ID, "pump", 0x33) }; + let seen = std::thread::spawn(|| dispatch_target(PRIMARY_IMAGE_CLASS_ID, "pump")) + .join() + .expect("pump thread panicked"); + assert_eq!( + seen, + Some(0x33), + "a pump thread must read the primary image" + ); + + // Entering is idempotent: a thread that already resolves to some image + // keeps it, so a second `js_gc_init` on the same thread is harmless. + let (before, after) = std::thread::spawn(|| { + enter_current_thread_image(); + let before = current_image_id(); + enter_current_thread_image(); + (before, current_image_id()) + }) + .join() + .expect("re-entering thread panicked"); + assert_eq!(before, after, "re-entering replaced the thread's image"); + } +} diff --git a/crates/perry-runtime/src/object/class_meta_registry.rs b/crates/perry-runtime/src/object/class_meta_registry.rs index a25febba36..0406bda1d8 100644 --- a/crates/perry-runtime/src/object/class_meta_registry.rs +++ b/crates/perry-runtime/src/object/class_meta_registry.rs @@ -2,13 +2,15 @@ //! `extends Error`, `Symbol.hasInstance` / `Symbol.toStringTag` hooks //! (split out of `object/mod.rs`, behavior-preserving). +use crate::object::class_image::{self, ImageTable, PARENT_DENSE_CAP}; use crate::registry_latch::RegistryLatch; use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::RwLock; -/// Global class registry mapping class_id -> parent_class_id for inheritance chain lookups -pub(crate) static CLASS_REGISTRY: RwLock>> = RwLock::new(None); +/// The calling image's class registry mapping class_id -> parent_class_id for +/// inheritance chain lookups (#8546 — see `object/class_image.rs`). +pub(crate) static CLASS_REGISTRY: ImageTable>>> = + ImageTable::new(|image| &image.parents); // ============================================================================ // Dense parent-edge table (#7769) @@ -30,23 +32,17 @@ pub(crate) static CLASS_REGISTRY: RwLock>> = RwLock::ne // the window (the reserved builtin bands `0xFFFF_00xx` / `0x7FFF_FFxx` and // the high-bit synthetic ids) keep using the map. // -// The array is `.bss` (zero-fill, no file bytes) and only the pages actually -// indexed are ever touched, so a program with 200 classes resides in one 4 KB -// page. +// The table is one 256 KiB zero-filled allocation per image (#8546: it lives +// in `ClassImageTables::parent_dense`, one per hosted application), reached +// through the same thread-local image resolution as every other class table. +// +// Encoding: `parent + 1` for every registered edge whose child id is +// `< PARENT_DENSE_CAP`; `0` means "no edge registered for this child". The +// `+1` bias is what lets a single word encode both "absent" and "present with +// parent id 0". The one id that cannot be biased (`u32::MAX`) arms +// [`PARENT_DENSE_INCOMPLETE`] instead of being stored. // ============================================================================ -/// Number of class ids covered by the dense parent table. -const PARENT_DENSE_CAP: usize = 1 << 16; - -/// `parent + 1` for every registered edge whose child id is `< PARENT_DENSE_CAP`; -/// `0` means "no edge registered for this child". -/// -/// The `+1` bias is what lets a single word encode both "absent" and "present -/// with parent id 0". The one id that cannot be biased (`u32::MAX`) arms -/// [`PARENT_DENSE_INCOMPLETE`] instead of being stored. -static PARENT_DENSE: [AtomicU32; PARENT_DENSE_CAP] = - [const { AtomicU32::new(0) }; PARENT_DENSE_CAP]; - /// Armed only if an in-window child id could NOT be represented densely (a /// `u32::MAX` parent — never produced by any id allocator, but the encoding /// must not silently lie). While idle, a zero slot for an in-window child @@ -70,7 +66,7 @@ pub(crate) fn parent_dense_store(class_id: u32, parent_class_id: u32) { PARENT_DENSE_INCOMPLETE.arm(); return; } - PARENT_DENSE[idx].store(parent_class_id.wrapping_add(1), Ordering::Release); + class_image::parent_dense_store(idx, parent_class_id.wrapping_add(1)); } /// Look up parent class ID from the registry. @@ -82,7 +78,7 @@ pub(crate) fn parent_dense_store(class_id: u32, parent_class_id: u32) { pub(crate) fn get_parent_class_id(class_id: u32) -> Option { let idx = class_id as usize; if idx < PARENT_DENSE_CAP { - let biased = PARENT_DENSE[idx].load(Ordering::Acquire); + let biased = class_image::parent_dense_load(idx); if biased != 0 { return Some(biased - 1); } @@ -101,7 +97,8 @@ pub(crate) fn get_parent_class_id(class_id: u32) -> Option { /// `GlobalRequest = global.Request`. Lets the runtime dynamic-construction /// path (`new (classExprValue)(...)` / ClassRef `new`) attach the underlying /// native fetch handle, matching what the static codegen `super()` path does. -static FETCH_PARENT_KIND: RwLock>> = RwLock::new(None); +static FETCH_PARENT_KIND: ImageTable>>> = + ImageTable::new(|image| &image.fetch_parent_kind); /// Idle until some class extends the global `Request`/`Response`. static FETCH_PARENT_LATCH: RegistryLatch = RegistryLatch::new(); @@ -152,7 +149,8 @@ fn fetch_parent_kind_slow(class_id: u32) -> Option { /// (`object/class_constructors.rs`), static-method lookup and vtable dispatch, /// so splicing the generic in between a specialization and its real base would /// re-run the wrong constructor. Only `instanceof` consults this one. -static CLASS_GENERIC_ORIGIN: RwLock>> = RwLock::new(None); +static CLASS_GENERIC_ORIGIN: ImageTable>>> = + ImageTable::new(|image| &image.generic_origin); /// Idle until a generic class is monomorphized. `class_chain_reaches` probes /// this table on EVERY hop of EVERY `instanceof`, so a program with no @@ -198,15 +196,17 @@ fn class_generic_origin_slow(class_id: u32) -> Option { g.as_ref()?.get(&class_id).copied() } -/// Global registry of class IDs that extend the built-in Error class -static EXTENDS_ERROR_REGISTRY: RwLock>> = RwLock::new(None); +/// The calling image's set of class IDs that extend the built-in Error class. +static EXTENDS_ERROR_REGISTRY: ImageTable>>> = + ImageTable::new(|image| &image.extends_error); /// Per-class `Symbol.hasInstance` static hook. Maps class_id → raw function /// pointer with signature `extern "C" fn(value: f64) -> f64` (NaN-boxed /// TAG_TRUE / TAG_FALSE result). Populated at module init from /// `__perry_wk_hasinstance_` top-level functions lifted by the HIR /// class lowering. -static CLASS_HAS_INSTANCE_REGISTRY: RwLock>> = RwLock::new(None); +static CLASS_HAS_INSTANCE_REGISTRY: ImageTable>>> = + ImageTable::new(|image| &image.has_instance); /// Per-class `Symbol.toStringTag` getter hook. Maps class_id → raw function /// pointer with signature `extern "C" fn(this: f64) -> f64` returning a @@ -214,7 +214,8 @@ static CLASS_HAS_INSTANCE_REGISTRY: RwLock>> = RwLock /// init from `__perry_wk_tostringtag_` top-level functions lifted by /// the HIR class lowering. Consulted by `js_object_to_string` so /// `Object.prototype.toString.call(x)` returns `[object ]`. -static CLASS_TO_STRING_TAG_REGISTRY: RwLock>> = RwLock::new(None); +static CLASS_TO_STRING_TAG_REGISTRY: ImageTable>>> = + ImageTable::new(|image| &image.to_string_tag); /// Idle until a class declares `static [Symbol.hasInstance]`. `js_instanceof` /// consults the table on every evaluation, ahead of the class-chain walk. diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index e41cae59eb..6531914c6a 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -1,4 +1,5 @@ use super::*; +use crate::object::class_image::ImageTable; use std::collections::HashMap; use std::sync::RwLock; @@ -22,12 +23,14 @@ pub unsafe extern "C" fn js_register_class_id(class_id: u32) { /// `metatype.name` to build the module token, so the empty default name /// from `v8::Function::builder(...)` would collide every module under the /// same token. (#1021.) -pub static CLASS_NAMES: RwLock>> = RwLock::new(None); +pub static CLASS_NAMES: ImageTable>>> = + ImageTable::new(|image| &image.names); /// Maps `class_id → ECMAScript constructor length` (formal parameters before /// the first default/rest parameter). Class refs are integer immediates rather /// than heap Function objects, so their own `length` property is reified from /// this table alongside `CLASS_NAMES`. -pub static CLASS_LENGTHS: RwLock>> = RwLock::new(None); +pub static CLASS_LENGTHS: ImageTable>>> = + ImageTable::new(|image| &image.lengths); /// Register the user-visible name of a class so the V8 bridge can label /// the V8-side wrapper for nice `metatype.name` reads. Idempotent. @@ -497,7 +500,8 @@ pub unsafe extern "C" fn js_text_encoding_stream_new() -> f64 { /// drizzle's `value.constructor === Object` duck checks, and the standard /// `({}).constructor === Object` semantics all match Node. The HIR /// lowering registers each anon shape's id here at module init. -pub static ANON_SHAPE_CLASS_IDS: RwLock>> = RwLock::new(None); +pub static ANON_SHAPE_CLASS_IDS: ImageTable>>> = + ImageTable::new(|image| &image.anon_shape_class_ids); /// Mark `class_id` as a synthetic anon-shape class so `.constructor` /// reads on instances of that class return the global `Object` diff --git a/crates/perry-runtime/src/object/class_registry/gc_roots.rs b/crates/perry-runtime/src/object/class_registry/gc_roots.rs index f514a8bf27..4a4bea8362 100644 --- a/crates/perry-runtime/src/object/class_registry/gc_roots.rs +++ b/crates/perry-runtime/src/object/class_registry/gc_roots.rs @@ -656,9 +656,11 @@ pub(crate) fn test_clear_class_side_table_roots() { *guard = None; } }); - if let Ok(mut guard) = CLASS_STATIC_ACCESSORS.write() { - *guard = None; - } + // The static-accessor table is deliberately NOT cleared here: it holds + // code addresses, not heap pointers, so it is not a root, and since #8546 + // it lives in the calling thread's class image (`object/class_image.rs`) + // rather than a `per_test_global!`, where a clear from a guard on another + // libtest thread would be the #7672 hazard this helper exists to avoid. NEXT_SYNTHETIC_CLASS_ID.store(0x8000_0000, std::sync::atomic::Ordering::Relaxed); } diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 1c655c8b0a..610e06c438 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -1,4 +1,5 @@ use super::*; +use crate::object::class_image::{ImageTable, StaticAccessorTable, StaticMethodTable}; use std::collections::HashMap; use std::sync::RwLock; @@ -234,8 +235,10 @@ pub struct ClassVTable { pub setters: HashMap, // setter func_ptr (signature: fn(this_f64, value_f64) -> f64) } -/// Global vtable registry: class_id -> vtable -pub static CLASS_VTABLE_REGISTRY: RwLock>> = RwLock::new(None); +/// Vtable registry of the calling thread's image (#8546 — see +/// `object/class_image.rs`): class_id -> vtable. +pub static CLASS_VTABLE_REGISTRY: ImageTable>>> = + ImageTable::new(|image| &image.vtables); /// #1788: per-class STATIC-method registry: class_id -> { name -> (func_ptr, /// param_count, has_rest) }. Static methods are emitted as `perry_static_*` @@ -247,13 +250,13 @@ pub static CLASS_VTABLE_REGISTRY: RwLock>> = Rw /// `js_class_static_method_call`. `has_rest` marks a trailing rest param /// (`static pipe(...args)`, effect's `pipe`/`dual`) so the dispatcher bundles /// the call args into an array for that slot. -pub static CLASS_STATIC_METHODS: RwLock>>> = - RwLock::new(None); +pub static CLASS_STATIC_METHODS: ImageTable>> = + ImageTable::new(|image| &image.static_methods); -per_test_global! { - pub static CLASS_STATIC_ACCESSORS: RwLock>>> = - RwLock::new(None); -} +/// Static accessors on the class constructor: class_id -> { name -> (getter +/// func_ptr, setter func_ptr) }, each 0 when that half is absent. +pub static CLASS_STATIC_ACCESSORS: ImageTable>> = + ImageTable::new(|image| &image.static_accessors); /// Spec `Function.prototype.length` per (class_id, method/accessor name) — the /// count of formal parameters before the first one with a default or a rest. @@ -261,16 +264,17 @@ per_test_global! { /// which overcounts methods with default-valued params; codegen computes the /// real `.length` at registration and stashes it here so `C.prototype.m.length` /// is exact (Test262 .../class/*/dflt-params-trailing-comma). -pub static CLASS_METHOD_BIND_LENGTHS: RwLock>> = - RwLock::new(None); +pub static CLASS_METHOD_BIND_LENGTHS: ImageTable>>> = + ImageTable::new(|image| &image.method_bind_lengths); /// Default-aware spec `.length` for STATIC methods, keyed (class_id, name). /// Distinct from `CLASS_METHOD_BIND_LENGTHS` (instance methods) so a class with /// both `static m(a, b = 1)` and `m(c)` keeps independent lengths instead of /// colliding on the (class_id, name) key. (Test262 *-method-static /// dflt-params-trailing-comma.) -pub static CLASS_STATIC_METHOD_BIND_LENGTHS: RwLock>> = - RwLock::new(None); +pub static CLASS_STATIC_METHOD_BIND_LENGTHS: ImageTable< + RwLock>>, +> = ImageTable::new(|image| &image.static_method_bind_lengths); crate::perry_thread_local! { pub static CLASS_SYMBOL_METHODS: RwLock>> = @@ -283,7 +287,8 @@ crate::perry_thread_local! { /// Set of all registered class ids. Populated at module init by codegen /// emitting `js_register_class_id(cid)` for every user class — even /// classes without any methods. Refs #618 / #420 followup. -pub static REGISTERED_CLASS_IDS: RwLock>> = RwLock::new(None); +pub static REGISTERED_CLASS_IDS: ImageTable>>> = + ImageTable::new(|image| &image.registered_class_ids); crate::perry_thread_local! { /// Issue #711 part 2: `function Base() {}; Base.prototype = obj` pattern. diff --git a/crates/perry-runtime/src/object/data_view_registry.rs b/crates/perry-runtime/src/object/data_view_registry.rs index d7f5bf433d..f7dd5829d0 100644 --- a/crates/perry-runtime/src/object/data_view_registry.rs +++ b/crates/perry-runtime/src/object/data_view_registry.rs @@ -1,10 +1,12 @@ use super::*; +use crate::object::class_image::ImageTable; -/// Global registry of class IDs that extend the built-in DataView class. -static EXTENDS_DATA_VIEW_REGISTRY: RwLock>> = - RwLock::new(None); -static EXTENDS_TYPED_ARRAY_REGISTRY: RwLock>> = - RwLock::new(None); +/// The calling image's set of class IDs that extend the built-in DataView +/// class (#8546 — see `object/class_image.rs`). +static EXTENDS_DATA_VIEW_REGISTRY: ImageTable>>> = + ImageTable::new(|image| &image.extends_data_view); +static EXTENDS_TYPED_ARRAY_REGISTRY: ImageTable>>> = + ImageTable::new(|image| &image.extends_typed_array); /// Mark a user-defined class as extending the built-in DataView class. #[no_mangle] diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 91756937f0..514ab12316 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -67,6 +67,7 @@ mod buffer_dispatch; mod class_constructors; mod class_gc_roots; mod class_handles; +pub mod class_image; mod class_registry; pub(crate) use class_registry::scan_current_new_target_root_mut; mod collection_proto_thunks; diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index e889b36a44..ccb86755ed 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -1062,13 +1062,18 @@ unsafe fn parallel_map_impl(array_val: f64, closure_val: f64) -> i64 { let mut all_results: Vec> = (0..chunks.len()).map(|_| Vec::new()).collect(); + // #8546: workers never run module init; they dispatch through the + // spawning image's class tables. + let class_image = crate::object::class_image::current_image_handle(); std::thread::scope(|scope| { let mut handles = Vec::with_capacity(chunks.len()); for (idx, chunk) in chunks.into_iter().enumerate() { let captures_ref = captures_arc.clone(); + let class_image = class_image.clone(); let handle = scope.spawn(move || { + crate::object::class_image::adopt_image(class_image); // #6185: own agent id before any allocation or enqueue, so this // worker's drains can't touch the spawner's queued work (and // anything it queues is tagged as its own). @@ -1312,17 +1317,21 @@ unsafe fn parallel_filter_impl(array_val: f64, closure_val: f64) -> i64 { let mut all_results: Vec> = (0..chunks.len()).map(|_| Vec::new()).collect(); + let class_image = crate::object::class_image::current_image_handle(); std::thread::scope(|scope| { let mut handles = Vec::with_capacity(chunks.len()); for (idx, chunk) in chunks.into_iter().enumerate() { let captures_ref = captures_arc.clone(); + let class_image = class_image.clone(); let handle = scope.spawn(move || { - // See parallel_map's worker: own agent (#6185) before anything - // can allocate or enqueue, scanner registration must precede - // any allocation, and the rebuilt closure must be rooted - // across the per-element deserialization allocations. + // See parallel_map's worker: adopt the spawning image (#8546), + // own agent (#6185) before anything can allocate or enqueue, + // scanner registration must precede any allocation, and the + // rebuilt closure must be rooted across the per-element + // deserialization allocations. + crate::object::class_image::adopt_image(class_image); let worker_agent = crate::agent::enter_worker_agent(); crate::gc::ensure_gc_initialized(); let mut kept = Vec::new(); @@ -1520,10 +1529,15 @@ unsafe fn spawn_impl(closure_val: f64) -> *mut crate::promise::Promise { // agent allowed to settle it. Captured here, on the spawning thread — // reading it inside the worker would yield the worker's own agent. let owner_agent = crate::agent::current_agent(); + // #8546: the worker runs the closure body only, never module init, so its + // class metadata (vtables, parents, constructors, …) must be the spawning + // image's — captured here, adopted first thing on the worker. + let class_image = crate::object::class_image::current_image_handle(); // ── 3. Spawn background thread ─────────────────────────────────── ACTIVE_THREAD_JOBS.fetch_add(1, Ordering::SeqCst); std::thread::spawn(move || { + crate::object::class_image::adopt_image(class_image); // #6185: claim an agent id for this worker BEFORE it can allocate or // enqueue anything, so every pointer it puts in a global queue is // tagged as its own — and so its own drains skip the spawner's work. diff --git a/crates/perry-stdlib/src/worker_threads.rs b/crates/perry-stdlib/src/worker_threads.rs index d434249e9b..d67fff9036 100644 --- a/crates/perry-stdlib/src/worker_threads.rs +++ b/crates/perry-stdlib/src/worker_threads.rs @@ -1238,7 +1238,13 @@ pub extern "C" fn js_worker_threads_worker_new(entry_ptr: i64, options: f64) -> ); let thread_options = options_state.clone(); + // #8546: the Worker re-runs its module bodies on its own thread, but it is + // the SAME image as its parent (same code addresses, same class ids), so it + // shares the parent's class tables instead of building a second copy. Its + // entry's `js_gc_init` then finds an image already installed and keeps it. + let class_image = perry_runtime::object::class_image::current_image_handle(); std::thread::spawn(move || { + perry_runtime::object::class_image::adopt_image(class_image); let previous_env = apply_worker_env(&thread_options.env); CURRENT_WORKER_ID.with(|id| id.set(worker_id)); CURRENT_WORKER_DATA.with(|slot| *slot.borrow_mut() = worker_data); diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 5ea63698b3..c7a64bd2d1 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -45,6 +45,10 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // one setting re-lowers must not be served from a build another kept on // statepoints. "PERRY_LL_RS4GC_MAX_INSTRS", + // #8883: the alloca-walk budget above which a function is stamped + // `disable-tail-calls` before the optimizer. It changes the generated + // code of the functions it trips on, so it is a cache input. + "PERRY_LL_TRE_MAX_ALLOCA_WALK", // #8583: the relocation estimate above which a function spills its GC roots // to a shadow frame. It changes which functions carry statepoints, so it // changes the generated code and must be a cache input. diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 291261c7e4..2f4693f480 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -1018,6 +1018,14 @@ fn compute_object_cache_key_with_env( .as_deref() .unwrap_or(""), ); + // #8883: the TailCallElim alloca-walk budget stamps `disable-tail-calls` + // on the functions it trips on, which changes their object code. + h.field( + "env_ll_tre_max_alloca_walk", + env_var("PERRY_LL_TRE_MAX_ALLOCA_WALK") + .as_deref() + .unwrap_or(""), + ); // #8583: root-spill threshold changes which functions carry statepoints. h.field( "env_root_spill_relocations", diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 9aea3708c9..c2432c9761 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -725,6 +725,7 @@ fn key_changes_with_codegen_env_vars() { "PERRY_RS4GC", "PERRY_LL_SIZE_OPT", "PERRY_LL_RS4GC_MAX_INSTRS", + "PERRY_LL_TRE_MAX_ALLOCA_WALK", "PERRY_ROOT_SPILL_RELOCATIONS", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_DISABLE_BUFFER_FAST_PATH",