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-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);