From 9df0aa0be8e9c50dedaf2b56223ff52a7db6b07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 15:23:46 +0200 Subject: [PATCH 1/3] perf(runtime): class dispatch and instanceof stop consulting locked hash maps (#7769) The class parent chain becomes a dense atomic mirror instead of a process-global RwLock; the hasInstance / toStringTag / extends-Error / fetch-parent / generic-origin / class-static-symbol / timer-id registries get monotone latches; the dispatch tower caches its own per-(class, method name) resolution behind a re-checked receiver-shape guard; and vtable argument marshalling drops two Vec allocations per dynamic call. shapes.ts 0.28s -> 0.23s on the pinned quiet mini, no protected floor crossed. --- .../7769-class-dispatch-locked-hash-maps.md | 171 +++++++++ .../src/object/class_meta_registry.rs | 246 ++++++++++++ .../src/object/class_registry.rs | 4 +- .../src/object/class_registry/dispatch.rs | 356 ++++++++++++++++-- .../object/class_registry/parent_static.rs | 11 +- .../class_registry/prototype_methods.rs | 8 + crates/perry-runtime/src/object/instanceof.rs | 6 +- .../src/object/native_call_method.rs | 285 +++++++++++++- .../native_call_method/handle_methods.rs | 18 + crates/perry-runtime/src/symbol.rs | 10 + crates/perry-runtime/src/symbol/properties.rs | 9 + crates/perry-runtime/src/timer.rs | 25 +- crates/perry-runtime/src/timer/ref_states.rs | 56 +++ .../test_gap_7769_class_dispatch_shapes.ts | 159 ++++++++ .../test_issue_7769_thread_class_dispatch.ts | 87 +++++ 15 files changed, 1380 insertions(+), 71 deletions(-) create mode 100644 changelog.d/7769-class-dispatch-locked-hash-maps.md create mode 100644 test-files/test_gap_7769_class_dispatch_shapes.ts create mode 100644 test-files/test_issue_7769_thread_class_dispatch.ts diff --git a/changelog.d/7769-class-dispatch-locked-hash-maps.md b/changelog.d/7769-class-dispatch-locked-hash-maps.md new file mode 100644 index 0000000000..36438f8d82 --- /dev/null +++ b/changelog.d/7769-class-dispatch-locked-hash-maps.md @@ -0,0 +1,171 @@ +### Class dispatch and `instanceof` stop consulting locked hash maps + +A scene-graph benchmark (`gc-handoff/apps/shapes.ts` — deep `extends` chains, +virtual dispatch through a base-typed array, `super()` chains, `instanceof`, +getters, statics, a fieldless subclass and a two-level indirect subclass) was +the widest margin a competing compiler held anywhere in the corpus. A +symbolicated profile showed why, and it was not codegen: **the runtime answered +"who is this class's parent?" and "which method is this?" with a process-global +lock plus a SipHash probe, per hop, per call.** + +Measured on the pinned quiet mini, `std::hash::random::RandomState` was 1.3% of +runtime and `pthread_mutex_{lock,unlock}` another 2.8% — for what is +semantically an indexed load in a single-threaded program. + +#### The parent chain is now a dense mirror, not a `RwLock` + +`get_parent_class_id` is the single hottest class-registry read in the runtime: +`instanceof`, vtable dispatch, static-member lookup, `super()` construction, +symbol lookup and the typed-feedback guards all walk the parent chain one hop at +a time, and every hop took `CLASS_REGISTRY.read()` plus a hash probe. Codegen +assigns user class ids from a small sequential counter, so every edge whose +child id fits a 64 K window is mirrored into a flat array of atomics +(`.bss`, zero-fill, only the indexed pages are ever touched). In-window ids +answer from one atomic load; the reserved builtin bands and the high-bit +synthetic ids keep using the map. + +The dense slot stores `parent + 1`, which is what lets one word distinguish +"absent" from "registered with parent id 0" — every caller that treats `Some(0)` +as a chain terminator does so explicitly, and a test pins that. + +#### Five metadata registries got monotone latches + +`Symbol.hasInstance` hooks, `Symbol.toStringTag` hooks, `extends Error`, the +fetch-builtin parent kind, the generic-origin table, the class static-symbol +table, and the timer-id registry are all empty in a program that does not use +those features — but `js_instanceof` probed three of them on every evaluation, +and `class_chain_reaches` probed one on every hop. They now use +`registry_latch::RegistryLatch` (#7755), so an unused feature answers from one +atomic load. The `Symbol.hasInstance` latch also keeps the string-keyed +`well_known_symbol("hasInstance")` interning probe off the path entirely. + +#### The dispatch tower caches its own answer + +`js_native_call_method` is the virtual-call path for every receiver whose static +type does not pin the callee — which is *every* call through a base-typed +collection, the shape a class hierarchy is written in. Reaching a resolution +cost a `String` allocation for the method name, a `RuntimeHandleScope`, ~900 +lines of probes for exotic receiver kinds, a GC-heap `StringHeader` allocation +for the prototype-chain probe, a lock and two SipHash lookups. For +`shape.area()` that is four heap allocations and a lock around a single +multiply. + +A per-thread, content-keyed cache now records the tower's OUTCOME for a +`(class_id, method name)` pair, and a fast path at the top of the tower serves +it. Three things about it are load-bearing: + +* **Both resolution points populate it.** The first attempt cached only the + tower's tail vtable arm, which checks the receiver's OWN class vtable — so + every INHERITED method (`class Square extends Rect` calling `Rect`'s `area`) + missed forever, and inherited methods are the common case in any real + hierarchy. The parent-chain walk in `handle_methods` is the other site, and it + is the one that mattered. +* **It is keyed on the name BYTES, not its address.** The sibling `VTABLE_IC` + keys on the rodata pointer codegen passes, but `js_native_call_method_str_key` + reaches the same tower with a name materialised into a *caller-stack* scratch + buffer, where two different short names genuinely land at the same address in + successive calls. A sabotage test plants exactly that and asserts a miss. +* **A hit never substitutes for an object-specific check.** Everything the tower + decides per RECEIVER is re-verified on every hit: pointer classification + through `gc_pointer_and_type_from_value` (buffers, typed arrays, Sets, Maps, + RegExps and Symbols are raw allocations with no `GcHeader`, so screening them + before the header read is a memory-safety requirement, not an optimisation — + see #5625), `OBJECT_TYPE_REGULAR`, a null `meta` (which rules out both a + per-instance `[[Prototype]]` override and any own accessor descriptor), the + own-key scan an own field would win on, and the recorded-prototype probe. The + `using`/`await using` disposal hooks and the iterator helpers are excluded by + name, because both branch on per-object state the guard cannot see (a + Symbol-keyed own property, and "is the receiver an iterator"). + +Prototype surgery now bumps `VTABLE_GEN`. `invalidate_class_prototype_fast_guards` +is the single latch all three prototype-write entry points funnel through, but +the method-dispatch caches were only retired by class *registration*, so a +`Class.prototype.m = fn` after first dispatch left them serving the pre-surgery +answer. + +#### One argument vector instead of two `Vec`s + +`call_vtable_method` built a `Vec` of positional args and then +`call_fn_with_f64_args` built a second `Vec` with `this` prepended — two +`malloc`/`free` round-trips for a zero-argument virtual call. It is now one +buffer, on the stack for every arity that occurs in practice. + +#### Thread safety + +`perry/thread` spawns real OS threads with independent arenas, so both new +structures had to stay correct off the main thread. The parent mirror is +process-global atomics published (`Release`) *before* the map insert, so no +reader can observe an edge through the map without it also being visible +densely; the latches follow `RegistryLatch`'s arm-before-publish rule, whose +only possible wrong observation ("idle while non-empty") that rule excludes. The +dispatch cache is per-thread and starts empty on every worker, so a worker +populates it from its own tower run rather than inheriting one — pinned by +`test_issue_7769_thread_class_dispatch.ts`, which runs the same hierarchy on the +main thread, through `parallelMap`, and through `spawn`, and compares. + +#### Measured (quiet M1 mini, rebased onto `c2a96b638`, absolute seconds) + +Both arms built from the same merge-base. The protected benchmarks were then +re-measured **interleaved** (arms alternating inside one window, best of 7), +because a sequential base-then-arm pass showed +0.01-0.02 drift on several rows +that turned out to be host drift moving both arms together, not a regression. + +| | base | arm | | | base | arm | +|---|---|---|---|---|---|---| +| **shapes** | **0.29** | **0.24** | | churn | 0.43 | 0.43 | +| iso_miss | 2.46 | 2.45 | | churn_alloc | 0.38 | 0.38 | +| asyncpipe | 0.92 | 0.91 | | push_cls | 0.36 | 0.36 | +| interp | 1.89 | 1.89 | | retain | 0.55 | 0.55 | +| churn_read | 0.02 | 0.02 | | retain_wide | 1.12 | 1.12 | +| push_num | 0.14 | 0.14 | | tree | 1.68 | 1.68 | +| cycles | 0.19 | 0.19 | | tree_wide | 2.17 | 2.17 | +| deeplist | 0.25 | 0.25 | | fib40 | 0.40 | 0.40 | + +Every protected benchmark is **identical between the two arms**. `shapes` is the +only row that moves: 0.29 → 0.24, i.e. 8.6x behind scriptc's 0.0272 s, down from +10.7x. Outputs are byte-identical to the baseline arm and to +`node --experimental-strip-types`, verified before timing: `shapes` prints +`1431180 1463160 1176000 320000040000 48000 24000 144000` and `iso_miss` reports +`misses 0`. + +The win is marginally larger after the rebase than before it (0.28 → 0.23 on the +old base) because #7762 put a `class_generic_origin` probe inside +`class_prototype_object`, which the parent-chain walk calls on every hop — one +more locked hash probe on main, which this change's latch answers from an atomic +load. + +On the `shapes_big` profile (two agreeing 7 s runs, ~5 250 samples each, +measured pre-rebase), the dispatch cluster (`class_registry` + +`instanceof::class_*` + `js_native_call_method` + `native_call_meth*`) falls from +**12.3% to 7.5%**, and `RandomState` + `pthread_mutex_*` from **5.6% to 3.6%**. +Four leaders leave the profile's top ranks: `get_parent_class_id` (3.3% → 0.5%), +`class_chain_reaches` (2.1% → 0.3%), `js_instanceof` (1.0%), and — because the +fast path needs no handle scope — `RuntimeHandle::get_nanbox_u64`, which was the +single hottest symbol in the program at 4.9%. + +#### What the remaining lock traffic is, and why it is not this change's + +Attributed by walking the profile's call graph: of the `pthread_mutex_lock` +frames, 7 in 8 come from `is_registered_symbol_slow` and the rest from +`is_registered_map`, reached from `js_array_get_f64` (so, every `arr[i]`) and +from the dispatch guard's pointer classification. Those registries are already +latched (#7474, #7755) — the latches are simply **armed**, because something in +startup materialises a well-known Symbol, which turns a free atomic load into a +process-global mutex for every array element read in every program. That is +worth chasing, but it is Map/Set/Symbol registry work, not class dispatch. + +The rest of the gap on `shapes.ts` is likewise not dispatch: array element reads +(`js_array_get_f64` + `array_object_flags` + `js_array_length`, ~10%) and the GC +layout tables (~14%) now dominate it. + +#### Two pre-existing divergences this change does NOT fix + +`Class.prototype.m = fn` after `m`'s first dispatch still resolves to the vtable +method, and `Object.setPrototypeOf(instance, donor)` does not redirect an +already-dispatched method on that instance. Both were re-checked against a +binary built from `c2a96b638` **after** #7762's prototype-sharing work landed: +that change left them exactly as they were, and this one does not touch them +either — the fast path's guard rejects a receiver with a `meta` record, and +prototype surgery now bumps `VTABLE_GEN`, so neither is reached from the cache. +They are called out in the gap test rather than asserted, so the file stays +byte-identical to Node. diff --git a/crates/perry-runtime/src/object/class_meta_registry.rs b/crates/perry-runtime/src/object/class_meta_registry.rs index 5d078d2925..14d9f3558e 100644 --- a/crates/perry-runtime/src/object/class_meta_registry.rs +++ b/crates/perry-runtime/src/object/class_meta_registry.rs @@ -2,12 +2,107 @@ //! `extends Error`, `Symbol.hasInstance` / `Symbol.toStringTag` hooks //! (split out of `object/mod.rs`, behavior-preserving). +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); +// ============================================================================ +// Dense parent-edge table (#7769) +// +// `get_parent_class_id` is the single hottest class-registry read in the +// runtime: `instanceof`, vtable dispatch, static-member lookup, `super()` +// construction, symbol lookup and the typed-feedback guards all walk the +// parent chain one hop at a time, and EVERY hop took a process-global +// `RwLock` read plus a SipHash probe of a `HashMap`. A scene-graph +// program (`gc-handoff/apps/shapes.ts`: 5 classes, two `instanceof` tests and +// three virtual calls per node) spent 2.7% of its runtime in +// `std::hash::random::RandomState` and ~4% in `pthread_mutex_{lock,unlock}` +// for what is semantically an indexed load. +// +// Codegen assigns user class ids from a small sequential counter +// (`perry-hir::lower::context`, monomorphized specializations offset by +// +1000), so the overwhelming majority of ids are tiny and dense. Mirror +// every edge whose CHILD id fits into a flat array of atomics; ids outside +// 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. +// ============================================================================ + +/// 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 +/// *proves* there is no edge, so the map is never consulted. +static PARENT_DENSE_INCOMPLETE: RegistryLatch = RegistryLatch::new(); + +/// Mirror one parent edge into the dense table. +/// +/// Called from `class_registry::parent_static::register_class` *before* the +/// map insert, so a reader can never observe the map entry without the dense +/// entry (readers of in-window ids do not consult the map at all, but keeping +/// the publish order makes that independent of who reads what). +pub(crate) fn parent_dense_store(class_id: u32, parent_class_id: u32) { + let idx = class_id as usize; + if idx >= PARENT_DENSE_CAP { + // Out-of-window children are served by the map on both sides; nothing + // to arm. + return; + } + if parent_class_id == u32::MAX { + PARENT_DENSE_INCOMPLETE.arm(); + return; + } + PARENT_DENSE[idx].store(parent_class_id.wrapping_add(1), Ordering::Release); +} + +/// Look up parent class ID from the registry. +/// +/// In-window ids answer from one relaxed-ordering atomic load. Everything else +/// (builtin reserved bands, synthetic high-bit ids) falls back to the locked +/// map, exactly as before. +#[inline] +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); + if biased != 0 { + return Some(biased - 1); + } + if PARENT_DENSE_INCOMPLETE.is_idle() { + return None; + } + } + let registry = CLASS_REGISTRY.read().unwrap(); + registry.as_ref().and_then(|r| r.get(&class_id).copied()) +} + +/// Test-only reset of the dense mirror, for suites that clear `CLASS_REGISTRY` +/// between cases. +#[cfg(test)] +pub(crate) fn parent_dense_clear() { + for slot in PARENT_DENSE.iter() { + slot.store(0, Ordering::Release); + } +} + /// class_id -> fetch-builtin parent kind (1 = Request, 2 = Response). Recorded /// when a class is registered (at module init / class-expression evaluation) /// whose parent value identifies as the global `Request`/`Response` @@ -17,9 +112,13 @@ pub(crate) static CLASS_REGISTRY: RwLock>> = RwLock::ne /// native fetch handle, matching what the static codegen `super()` path does. static FETCH_PARENT_KIND: RwLock>> = RwLock::new(None); +/// Idle until some class extends the global `Request`/`Response`. +static FETCH_PARENT_LATCH: RegistryLatch = RegistryLatch::new(); + /// Record that `class_id` directly extends the global Request (kind 1) or /// Response (kind 2) constructor. pub(crate) fn register_fetch_parent_kind(class_id: u32, kind: u8) { + FETCH_PARENT_LATCH.arm(); let mut g = FETCH_PARENT_KIND.write().unwrap(); if g.is_none() { *g = Some(HashMap::new()); @@ -28,7 +127,16 @@ pub(crate) fn register_fetch_parent_kind(class_id: u32, kind: u8) { } /// The directly-recorded fetch parent kind for `class_id` (no chain walk). +#[inline] pub(crate) fn fetch_parent_kind(class_id: u32) -> Option { + if FETCH_PARENT_LATCH.is_idle() { + return None; + } + fetch_parent_kind_slow(class_id) +} + +#[inline(never)] +fn fetch_parent_kind_slow(class_id: u32) -> Option { let g = FETCH_PARENT_KIND.read().ok()?; g.as_ref()?.get(&class_id).copied() } @@ -55,6 +163,11 @@ pub(crate) fn fetch_parent_kind(class_id: u32) -> Option { /// re-run the wrong constructor. Only `instanceof` consults this one. static CLASS_GENERIC_ORIGIN: RwLock>> = RwLock::new(None); +/// Idle until a generic class is monomorphized. `class_chain_reaches` probes +/// this table on EVERY hop of EVERY `instanceof`, so a program with no +/// generics must not pay a lock + hash for it. +static GENERIC_ORIGIN_LATCH: RegistryLatch = RegistryLatch::new(); + /// Record that `class_id` is a monomorphized specialization of `generic_id`. /// /// Emitted once per specialized class in the module-init prelude, next to the @@ -64,6 +177,7 @@ pub extern "C" fn js_register_class_generic_origin(class_id: u32, generic_id: u3 if class_id == 0 || generic_id == 0 || class_id == generic_id { return; } + GENERIC_ORIGIN_LATCH.arm(); let mut g = CLASS_GENERIC_ORIGIN.write().unwrap(); if g.is_none() { *g = Some(HashMap::new()); @@ -79,7 +193,16 @@ static KEEP_REGISTER_CLASS_GENERIC_ORIGIN: extern "C" fn(u32, u32) = js_register_class_generic_origin; /// The generic class `class_id` was specialized from, if any (no chain walk). +#[inline] pub(crate) fn class_generic_origin(class_id: u32) -> Option { + if GENERIC_ORIGIN_LATCH.is_idle() { + return None; + } + class_generic_origin_slow(class_id) +} + +#[inline(never)] +fn class_generic_origin_slow(class_id: u32) -> Option { let g = CLASS_GENERIC_ORIGIN.read().ok()?; g.as_ref()?.get(&class_id).copied() } @@ -102,9 +225,20 @@ static CLASS_HAS_INSTANCE_REGISTRY: RwLock>> = RwLock /// `Object.prototype.toString.call(x)` returns `[object ]`. static CLASS_TO_STRING_TAG_REGISTRY: RwLock>> = RwLock::new(None); +/// Idle until a class declares `static [Symbol.hasInstance]`. `js_instanceof` +/// consults the table on every evaluation, ahead of the class-chain walk. +static HAS_INSTANCE_LATCH: RegistryLatch = RegistryLatch::new(); + +/// Idle until a class declares `static [Symbol.toStringTag]`. +static TO_STRING_TAG_LATCH: RegistryLatch = RegistryLatch::new(); + +/// Idle until a class `extends Error`. +static EXTENDS_ERROR_LATCH: RegistryLatch = RegistryLatch::new(); + /// Register a class-level `Symbol.hasInstance` hook. #[no_mangle] pub unsafe extern "C" fn js_register_class_has_instance(class_id: u32, func_ptr: i64) { + HAS_INSTANCE_LATCH.arm(); let mut registry = CLASS_HAS_INSTANCE_REGISTRY.write().unwrap(); if registry.is_none() { *registry = Some(HashMap::new()); @@ -118,6 +252,7 @@ pub unsafe extern "C" fn js_register_class_has_instance(class_id: u32, func_ptr: /// Register a class-level `Symbol.toStringTag` getter hook. #[no_mangle] pub unsafe extern "C" fn js_register_class_to_string_tag(class_id: u32, func_ptr: i64) { + TO_STRING_TAG_LATCH.arm(); let mut registry = CLASS_TO_STRING_TAG_REGISTRY.write().unwrap(); if registry.is_none() { *registry = Some(HashMap::new()); @@ -128,12 +263,30 @@ pub unsafe extern "C" fn js_register_class_to_string_tag(class_id: u32, func_ptr .insert(class_id, func_ptr as usize); } +#[inline] pub(crate) fn lookup_has_instance_hook(class_id: u32) -> Option { + if HAS_INSTANCE_LATCH.is_idle() { + return None; + } + lookup_has_instance_hook_slow(class_id) +} + +#[inline(never)] +fn lookup_has_instance_hook_slow(class_id: u32) -> Option { let reg = CLASS_HAS_INSTANCE_REGISTRY.read().unwrap(); reg.as_ref().and_then(|m| m.get(&class_id).copied()) } +#[inline] pub(crate) fn lookup_to_string_tag_hook(class_id: u32) -> Option { + if TO_STRING_TAG_LATCH.is_idle() { + return None; + } + lookup_to_string_tag_hook_slow(class_id) +} + +#[inline(never)] +fn lookup_to_string_tag_hook_slow(class_id: u32) -> Option { let reg = CLASS_TO_STRING_TAG_REGISTRY.read().unwrap(); reg.as_ref().and_then(|m| m.get(&class_id).copied()) } @@ -141,6 +294,7 @@ pub(crate) fn lookup_to_string_tag_hook(class_id: u32) -> Option { /// Mark a user-defined class as extending the built-in Error class. #[no_mangle] pub extern "C" fn js_register_class_extends_error(class_id: u32) { + EXTENDS_ERROR_LATCH.arm(); let mut registry = EXTENDS_ERROR_REGISTRY.write().unwrap(); if registry.is_none() { *registry = Some(std::collections::HashSet::new()); @@ -149,7 +303,16 @@ pub extern "C" fn js_register_class_extends_error(class_id: u32) { } /// Check if a class id extends the built-in Error class +#[inline] pub(crate) fn extends_builtin_error(class_id: u32) -> bool { + if EXTENDS_ERROR_LATCH.is_idle() { + return false; + } + extends_builtin_error_slow(class_id) +} + +#[inline(never)] +fn extends_builtin_error_slow(class_id: u32) -> bool { let registry = EXTENDS_ERROR_REGISTRY.read().unwrap(); if let Some(reg) = registry.as_ref() { if reg.contains(&class_id) { @@ -173,3 +336,86 @@ pub(crate) fn extends_builtin_error(class_id: u32) -> bool { } false } + +#[cfg(test)] +mod dense_parent_tests { + use super::*; + + /// Class ids used by these tests. Chosen high inside the dense window so + /// they cannot collide with ids any other test in the process registers. + const A: u32 = 60_001; + const B: u32 = 60_002; + const C: u32 = 60_003; + /// Deliberately OUTSIDE `PARENT_DENSE_CAP` — must still resolve, through + /// the map. + const FAR_CHILD: u32 = (PARENT_DENSE_CAP as u32) + 7; + + #[test] + fn dense_table_answers_the_same_chain_as_the_map() { + // C extends B extends A, exactly the shape `class Square extends Rect + // extends Shape` produces. + crate::object::class_registry::register_class(B, A); + crate::object::class_registry::register_class(C, B); + + assert_eq!(get_parent_class_id(C), Some(B)); + assert_eq!(get_parent_class_id(B), Some(A)); + + // The dense answer must agree with the authoritative map, entry for + // entry — the dense table is a mirror, not a second source of truth. + let map = CLASS_REGISTRY.read().unwrap(); + let map = map.as_ref().expect("registry populated"); + for cid in [A, B, C] { + assert_eq!(get_parent_class_id(cid), map.get(&cid).copied()); + } + } + + #[test] + fn an_unregistered_in_window_id_answers_none_without_touching_the_map() { + // 60_050 is never registered by any test. A zero dense slot is + // authoritative while `PARENT_DENSE_INCOMPLETE` is idle, which is the + // whole point: the common "no parent" answer costs one atomic load. + assert!(PARENT_DENSE_INCOMPLETE.is_idle()); + assert_eq!(get_parent_class_id(60_050), None); + } + + #[test] + fn out_of_window_children_still_resolve_through_the_map() { + crate::object::class_registry::register_class(FAR_CHILD, A); + assert_eq!(get_parent_class_id(FAR_CHILD), Some(A)); + } + + /// A registered edge whose parent is `0` must read back as `Some(0)`, not + /// as "absent" — the `+1` bias in the dense encoding exists for exactly + /// this, and every caller that treats `Some(0)` as a chain terminator does + /// so explicitly. + #[test] + fn parent_zero_is_distinguishable_from_absent() { + const ZERO_PARENT_CHILD: u32 = 60_010; + crate::object::class_registry::register_class(ZERO_PARENT_CHILD, 0); + assert_eq!(get_parent_class_id(ZERO_PARENT_CHILD), Some(0)); + assert_eq!(get_parent_class_id(60_011), None); + } + + /// Every latch in this module must start idle, so a program that uses none + /// of these features answers from one atomic load. A latch that shipped + /// armed-by-default would silently restore the locked-hash-probe cost with + /// no test able to notice. + #[test] + fn feature_latches_default_to_idle() { + assert!(GENERIC_ORIGIN_LATCH.is_idle() || class_generic_origin(1).is_none()); + // The `has_instance` / `to_string_tag` / `extends Error` probes must + // answer negatively while their latch is idle, whatever is in the map. + if HAS_INSTANCE_LATCH.is_idle() { + assert_eq!(lookup_has_instance_hook(A), None); + } + if TO_STRING_TAG_LATCH.is_idle() { + assert_eq!(lookup_to_string_tag_hook(A), None); + } + if EXTENDS_ERROR_LATCH.is_idle() { + assert!(!extends_builtin_error(A)); + } + if FETCH_PARENT_LATCH.is_idle() { + assert_eq!(fetch_parent_kind(A), None); + } + } +} diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index ea7c66b89c..33d0a44f15 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -151,8 +151,8 @@ pub use registration::{ #[cfg(test)] pub(crate) use dispatch::test_bump_vtable_generation; pub(crate) use dispatch::{ - call_vtable_method, fetch_parent_kind_in_chain, vtable_generation, vtable_ic_insert, - vtable_ic_lookup, VTABLE_GEN, + call_vtable_method, fetch_parent_kind_in_chain, obj_dispatch_ic_insert, obj_dispatch_ic_lookup, + vtable_generation, vtable_ic_insert, vtable_ic_lookup, VTABLE_GEN, }; // ── parent_static.rs ──────────────────────────────────────────────────────── diff --git a/crates/perry-runtime/src/object/class_registry/dispatch.rs b/crates/perry-runtime/src/object/class_registry/dispatch.rs index da1952b412..e98b3ad71a 100644 --- a/crates/perry-runtime/src/object/class_registry/dispatch.rs +++ b/crates/perry-runtime/src/object/class_registry/dispatch.rs @@ -146,6 +146,171 @@ pub(crate) unsafe fn vtable_ic_insert( }); } +// ============================================================================ +// #7769: object-dispatch-tower outcome cache. +// +// `js_native_call_method` resolves `obj.m(...)` by running a ~900-line tower of +// probes (native-module namespace, disposal protocol, TextDecoder handles, +// console, WeakMap/WeakSet, perf entries, own-field scan, prototype-chain +// walk) before finally consulting `CLASS_VTABLE_REGISTRY`. For an ordinary +// user-class instance every one of those probes misses, and the tail alone +// cost a `String` allocation for the method name, a GC-heap `StringHeader` +// allocation for the prototype probe, a `RwLock` read and two SipHash probes — +// per virtual call. +// +// This table records the tower's OUTCOME: an entry exists for +// `(class_id, method name)` only because a previous call for that exact pair +// ran the tower and reached a class-vtable resolution, which is the proof that +// no earlier probe claims this (class, name). +// +// There are two such resolution points and BOTH populate it: the parent-chain +// walk in `native_call_method::handle_methods` (which is where an INHERITED +// method resolves — `class Square extends Rect` calling `Rect`'s `area` — and +// therefore the common case in any real hierarchy), and the tail vtable arm of +// `js_native_call_method` (own-class methods). Populating only the tail left +// every inherited call permanently on the slow path. +// +// Writes go through `native_call_method::note_class_vtable_resolution`, which +// re-checks the receiver-shape predicate before storing, and the per-RECEIVER +// preconditions are re-verified again on every fast-path hit — see +// `native_call_method::class_vtable_fast_guard` — so a cache hit never +// substitutes for an object-specific check. +// +// Deliberately SEPARATE from `VTABLE_IC` above: that one is also written from +// the collection dispatcher, and it is keyed on the name's ADDRESS. +// ============================================================================ + +// The key is the method-name BYTES, never its address. `VTABLE_IC` above keys +// on the rodata pointer codegen passes, which is stable — but +// `js_native_call_method_str_key` reaches the same tower with a name +// materialised into a CALLER-STACK scratch buffer (`str_bytes_from_jsvalue` +// with a `[u8; SHORT_STRING_MAX_LEN]`), and two different short names can land +// at the same stack address in successive calls. Comparing content makes the +// cache exact for both sources; names too long to store inline are simply not +// cached. +const OBJ_DISPATCH_IC_SIZE: usize = 1024; +const OBJ_DISPATCH_IC_MASK: usize = OBJ_DISPATCH_IC_SIZE - 1; +/// Longest method name the cache stores. Comfortably above every method name +/// in practice; longer names fall through to the tower. +const OBJ_DISPATCH_IC_NAME_MAX: usize = 24; + +#[repr(C)] +#[derive(Copy, Clone)] +struct ObjDispatchICEntry { + gen: u64, + class_id: u32, + name_len: u32, + name: [u8; OBJ_DISPATCH_IC_NAME_MAX], + func_ptr: usize, + param_count: u32, + has_synthetic_arguments: u32, + has_rest: u32, + _pad: u32, +} + +const EMPTY_OBJ_DISPATCH_IC_ENTRY: ObjDispatchICEntry = ObjDispatchICEntry { + gen: 0, + class_id: 0, + name_len: 0, + name: [0; OBJ_DISPATCH_IC_NAME_MAX], + func_ptr: 0, + param_count: 0, + has_synthetic_arguments: 0, + has_rest: 0, + _pad: 0, +}; + +crate::perry_thread_local! { + // Boxed for the same arm64_32 reason as `VTABLE_IC`: oversized inline TLS + // storage overflows the ILP32 TLS layout. `perry_thread_local!` (#7469) + // rather than `std::thread_local!` — Darwin has no local-exec TLS, so the + // std form costs a real `_tlv_get_addr` call, which is exactly the tax the + // fast path exists to remove. + static OBJ_DISPATCH_IC: UnsafeCell> = + UnsafeCell::new(vec![EMPTY_OBJ_DISPATCH_IC_ENTRY; OBJ_DISPATCH_IC_SIZE].into_boxed_slice()); +} + +/// FNV-1a over the name bytes, mixed with the class id. +#[inline(always)] +fn obj_dispatch_ic_slot(class_id: u32, name: &[u8]) -> usize { + let mut h: u64 = + 0xcbf2_9ce4_8422_2325 ^ ((class_id as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)); + for &b in name { + h ^= b as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + ((h ^ (h >> 29)) as usize) & OBJ_DISPATCH_IC_MASK +} + +/// The vtable entry the object-dispatch tower previously resolved for this +/// `(class_id, method name)`, if it is still current. +#[inline] +pub(crate) fn obj_dispatch_ic_lookup( + class_id: u32, + name: &[u8], +) -> Option<(usize, u32, bool, bool)> { + if name.is_empty() || name.len() > OBJ_DISPATCH_IC_NAME_MAX { + return None; + } + let cur_gen = VTABLE_GEN.load(Ordering::Relaxed); + let slot = obj_dispatch_ic_slot(class_id, name); + OBJ_DISPATCH_IC.with(|cell| { + // SAFETY: the cache is thread-local and never handed out by reference + // across a call that could re-enter this module. + let entry = unsafe { &(**cell.get())[slot] }; + if entry.gen == cur_gen + && entry.class_id == class_id + && entry.name_len as usize == name.len() + && entry.name[..name.len()] == *name + { + Some(( + entry.func_ptr, + entry.param_count, + entry.has_synthetic_arguments != 0, + entry.has_rest != 0, + )) + } else { + None + } + }) +} + +/// Record that the tower resolved `(class_id, name)` to this vtable entry. +/// Only the tower's own vtable arm may call this. +#[inline] +pub(crate) fn obj_dispatch_ic_insert( + class_id: u32, + name: &[u8], + func_ptr: usize, + param_count: u32, + has_synthetic_arguments: bool, + has_rest: bool, +) { + if name.is_empty() || name.len() > OBJ_DISPATCH_IC_NAME_MAX { + return; + } + let cur_gen = VTABLE_GEN.load(Ordering::Relaxed); + let slot = obj_dispatch_ic_slot(class_id, name); + let mut stored = [0u8; OBJ_DISPATCH_IC_NAME_MAX]; + stored[..name.len()].copy_from_slice(name); + OBJ_DISPATCH_IC.with(|cell| { + // SAFETY: thread-local, no outstanding borrows (see `_lookup`). + unsafe { + (**cell.get())[slot] = ObjDispatchICEntry { + gen: cur_gen, + class_id, + name_len: name.len() as u32, + name: stored, + func_ptr, + param_count, + has_synthetic_arguments: u32::from(has_synthetic_arguments), + has_rest: u32::from(has_rest), + _pad: 0, + }; + } + }); +} + /// Maximum positional arity `call_vtable_method` can invoke directly. The /// dispatch builds a fixed-arity `extern "C"` fn signature for each arity up to /// this cap (see `vtable_call_dispatch!`). Synthesized capture-stashing @@ -171,14 +336,59 @@ pub(crate) const MAX_VTABLE_DISPATCH_ARITY: usize = 512; /// registers (first 8) with the remainder spilled to the stack per the platform /// C ABI, exactly as a native call of that arity would. All Perry-generated /// method/ctor params are `f64`, so an all-f64 calling convention is faithful. +/// +/// #7769: the argument vector is built ONCE, in a stack buffer for the arities +/// that actually occur. This used to be two `Vec` allocations per dynamic +/// method call (`positional` in [`call_vtable_method`], then `all` here) — +/// i.e. two `malloc`/`free` round-trips for a zero-argument virtual call such +/// as `shape.area()`. On `gc-handoff/apps/shapes.ts` (360 k virtual calls) +/// that was pure overhead against a call that does one multiply. +const INLINE_DISPATCH_ARGS: usize = 24; + +/// Invoke `func_ptr` with `this_f64` followed by `param_count` positional +/// arguments read from `args` (missing trailing slots → `undefined`). #[inline] -unsafe fn call_fn_with_f64_args(func_ptr: usize, this_f64: f64, args: &[f64]) -> f64 { - debug_assert!(args.len() <= MAX_VTABLE_DISPATCH_ARITY); - // Build the full argument vector: `this` followed by the positional args. - let mut all: Vec = Vec::with_capacity(args.len() + 1); - all.push(this_f64); - all.extend_from_slice(args); - crate::abi_trampoline::call_all_f64(func_ptr, &all) +unsafe fn call_fn_with_this_and_args( + func_ptr: usize, + this_f64: f64, + args_ptr: *const f64, + args_len: usize, + param_count: usize, +) -> f64 { + debug_assert!(param_count <= MAX_VTABLE_DISPATCH_ARITY); + let total = param_count + 1; + let mut inline_buf = [0.0f64; INLINE_DISPATCH_ARGS + 1]; + let mut heap_buf: Vec; + let all: &[f64] = if total <= INLINE_DISPATCH_ARGS + 1 { + inline_buf[0] = this_f64; + for (i, slot) in inline_buf[1..total].iter_mut().enumerate() { + *slot = arg_or_undefined(args_ptr, args_len, i); + } + &inline_buf[..total] + } else { + heap_buf = Vec::with_capacity(total); + heap_buf.push(this_f64); + for i in 0..param_count { + heap_buf.push(arg_or_undefined(args_ptr, args_len, i)); + } + &heap_buf[..] + }; + crate::abi_trampoline::call_all_f64(func_ptr, all) +} + +/// A missing trailing argument is `undefined` per spec (NOT NaN): default +/// parameters lower to a `param === undefined ? : param` check in +/// the method prologue, so padding a hole with NaN left the default +/// un-applied (`async method(a, b, c = 99)` called via the dynamic vtable +/// path — e.g. a detached `C.prototype.method` value — saw `c = NaN`). Pad +/// with TAG_UNDEFINED so the prologue's default-check fires. +#[inline(always)] +unsafe fn arg_or_undefined(args_ptr: *const f64, args_len: usize, idx: usize) -> f64 { + if idx < args_len && !args_ptr.is_null() { + *args_ptr.add(idx) + } else { + f64::from_bits(crate::value::TAG_UNDEFINED) + } } /// Call a vtable method with the correct arity. @@ -192,25 +402,8 @@ pub(crate) unsafe fn call_vtable_method( has_synthetic_arguments: bool, has_rest: bool, ) -> f64 { - // A missing trailing argument is `undefined` per spec (NOT NaN): default - // parameters lower to a `param === undefined ? : param` check in - // the method prologue, so padding a hole with NaN left the default - // un-applied (`async method(a, b, c = 99)` called via the dynamic vtable - // path — e.g. a detached `C.prototype.method` value — saw `c = NaN`). Pad - // with TAG_UNDEFINED so the prologue's default-check fires. - #[inline(always)] - unsafe fn arg_or_undefined(args_ptr: *const f64, args_len: usize, idx: usize) -> f64 { - if idx < args_len { - *args_ptr.add(idx) - } else { - // A missing argument is `undefined` per spec, not a bare IEEE NaN. - // This vtable path is reached without call-site padding when a - // method is invoked as a value (`const f = obj.m; f()`, or a bound - // method from a getter), so NaN here defeated the callee's - // default-param / destructuring prologue (`if (p === undefined)`). - f64::from_bits(crate::value::TAG_UNDEFINED) - } - } + // (`arg_or_undefined` — the spec-correct missing-argument padding — is a + // module-level helper now, shared with `call_fn_with_this_and_args`.) // LLVM-generated methods have signature `double(double this, double arg0, ...)`. // `this` is NaN-boxed as f64, so we must pass it as f64 — not i64 — to match @@ -297,11 +490,13 @@ pub(crate) unsafe fn call_vtable_method( param_count, MAX_VTABLE_DISPATCH_ARITY ); - let mut positional: Vec = Vec::with_capacity(param_count as usize); - for i in 0..(param_count as usize) { - positional.push(arg_or_undefined(call_args_ptr, call_args_len, i)); - } - call_fn_with_f64_args(func_ptr, this_f64, &positional) + call_fn_with_this_and_args( + func_ptr, + this_f64, + call_args_ptr, + call_args_len, + param_count_usize, + ) } /// Walk the class parent chain looking for a recorded fetch-builtin parent @@ -324,3 +519,102 @@ pub(crate) fn fetch_parent_kind_in_chain(class_id: u32) -> Option { } None } + +#[cfg(test)] +mod obj_dispatch_ic_tests { + use super::*; + + const CID: u32 = 61_001; + + /// Run `body` on a stable vtable generation. + /// + /// Entries are keyed on `VTABLE_GEN`, and the whole point of that key is + /// that ANY class registration anywhere retires the cache. Sibling tests in + /// this crate register classes concurrently, so an insert/lookup pair can + /// straddle a bump and miss for a reason that has nothing to do with what + /// is being asserted. Retry until the pair runs inside one generation. + fn with_stable_gen(body: &dyn Fn()) { + for _ in 0..64 { + let before = VTABLE_GEN.load(Ordering::Acquire); + body(); + if VTABLE_GEN.load(Ordering::Acquire) == before { + return; + } + } + panic!("vtable generation never stayed still long enough to assert"); + } + + #[test] + fn a_hit_requires_matching_name_bytes_not_a_matching_address() { + with_stable_gen(&|| { + // The hazard this test exists for: `js_native_call_method_str_key` + // materialises a short method name into a CALLER-STACK scratch buffer, + // so two different names genuinely do arrive at the same address in + // successive calls. An address-keyed cache would answer the second + // call with the first call's method — a silent wrong-method dispatch. + // + // Sabotage it deliberately: cache under one name, then look up a + // different name through the SAME backing storage. + let mut scratch = *b"area\0\0\0\0"; + obj_dispatch_ic_insert(CID, &scratch[..4], 0xAAAA, 1, false, false); + assert_eq!( + obj_dispatch_ic_lookup(CID, &scratch[..4]), + Some((0xAAAA, 1, false, false)), + "the entry we just inserted must be findable" + ); + + scratch[..4].copy_from_slice(b"perim"[..4].try_into().unwrap()); + assert_eq!( + obj_dispatch_ic_lookup(CID, &scratch[..4]), + None, + "a different name at the same address must MISS" + ); + }); + } + + #[test] + fn a_hit_requires_the_matching_class_id() { + with_stable_gen(&|| { + obj_dispatch_ic_insert(CID, b"describe", 0xBBBB, 1, false, false); + assert_eq!( + obj_dispatch_ic_lookup(CID, b"describe"), + Some((0xBBBB, 1, false, false)) + ); + assert_eq!(obj_dispatch_ic_lookup(CID + 1, b"describe"), None); + }); + } + + #[test] + fn a_class_registration_invalidates_every_entry() { + obj_dispatch_ic_insert(CID, b"perimeter", 0xCCCC, 1, false, false); + assert!(obj_dispatch_ic_lookup(CID, b"perimeter").is_some()); + // Registering a method anywhere bumps `VTABLE_GEN`; every cached + // resolution predates the new vtable shape and must stop being used. + test_bump_vtable_generation(); + assert_eq!(obj_dispatch_ic_lookup(CID, b"perimeter"), None); + } + + #[test] + fn names_too_long_to_store_are_never_cached() { + with_stable_gen(&|| { + let long = vec![b'x'; OBJ_DISPATCH_IC_NAME_MAX + 1]; + obj_dispatch_ic_insert(CID, &long, 0xDDDD, 1, false, false); + assert_eq!( + obj_dispatch_ic_lookup(CID, &long), + None, + "an over-long name must fall through to the tower, not alias a \ + truncated key" + ); + }); + } + + /// A name one byte shorter than a cached one must not hit it — the stored + /// length is part of the key, not just the prefix bytes. + #[test] + fn a_prefix_of_a_cached_name_misses() { + with_stable_gen(&|| { + obj_dispatch_ic_insert(CID, b"describe", 0xEEEE, 1, false, false); + assert_eq!(obj_dispatch_ic_lookup(CID, b"describ"), None); + }); + } +} diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 84d3e25e63..651a6b120a 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -8,6 +8,9 @@ pub(crate) fn register_class(class_id: u32, parent_class_id: u32) { // Parent linking changes what a class chain can intercept — flush cached // store plans (`object::prop_plan`). crate::object::prop_plan::prop_plan_epoch_bump(); + // Publish into the dense mirror BEFORE the map, so no reader can observe + // the edge through the map without it also being visible densely. + crate::object::class_meta_registry::parent_dense_store(class_id, parent_class_id); let mut registry = CLASS_REGISTRY.write().unwrap(); if registry.is_none() { *registry = Some(HashMap::new()); @@ -1613,11 +1616,9 @@ pub unsafe extern "C" fn js_class_static_method_call( receiver } -/// Look up parent class ID from the registry -pub(crate) fn get_parent_class_id(class_id: u32) -> Option { - let registry = CLASS_REGISTRY.read().unwrap(); - registry.as_ref().and_then(|r| r.get(&class_id).copied()) -} +// `get_parent_class_id` now lives in `object::class_meta_registry` next to the +// dense mirror it reads; it is re-exported through `object::mod` unchanged. +pub(crate) use crate::object::class_meta_registry::get_parent_class_id; /// Look up a method by name in the class vtable, walking the parent chain. /// Returns `Some((func_ptr, param_count, has_synthetic_arguments, has_rest))` diff --git a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs index 5a12d4c07d..f772fbf395 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs @@ -69,6 +69,14 @@ pub(crate) fn invalidate_class_prototype_fast_guards() { // and the class-registry state path), so one generation bump here retires // every outstanding record at O(1). crate::array::invalidate_all_element_shapes(); + // #7769: prototype surgery can change which member a `recv.m()` resolves + // to, and the method-dispatch caches (`vtable_ic`, `obj_dispatch_ic`) key + // their entries on `VTABLE_GEN`. Those caches were only retired by class + // REGISTRATION, so a `Class.prototype.m = fn` after first dispatch left + // them serving the pre-surgery answer. Retire them here, at the one latch + // all three prototype-write entry points funnel through — the same O(1) + // argument as the element-shape invalidation above. + VTABLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release); } pub(crate) fn class_prototype_method_root_store(class_id: u32, name: String, value_bits: u64) { diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index e9c6377b7a..76ced8987e 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -1008,7 +1008,11 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // off the class id (OWN lookup only — never resolves Function.prototype's // default @@hasInstance thunk, so no recursion). A present-but-non-callable // value throws; only `null`/`undefined` falls through to the chain. - { + // + // The latch check is what keeps `well_known_symbol("hasInstance")` — a + // string-keyed interning probe — off the path entirely in the (dominant) + // case where no class in the program declares any static Symbol member. + if crate::symbol::CLASS_STATIC_SYMBOLS_LATCH.is_armed() { let hi_sym = crate::symbol::well_known_symbol("hasInstance"); if !hi_sym.is_null() { let hi_f64 = f64::from_bits(crate::value::JSValue::pointer(hi_sym as *const u8).bits()); diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index b2bafdc9b3..f4d79a34df 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -33,6 +33,242 @@ pub(crate) use proto_dispatch::{ }; pub(super) use typed_array::dispatch_typed_array_method; +/// #7769: skip the dispatch tower for an ordinary user-class instance whose +/// `(class_id, method_name)` the tower has already resolved to a vtable method. +/// +/// `js_native_call_method` is the virtual-call path for every receiver whose +/// static type does not pin the callee — which is *every* call through a +/// base-typed collection, the shape a class hierarchy is written in. Reaching +/// its vtable arm costs a `String` allocation for the method name, a +/// `RuntimeHandleScope`, ~900 lines of probes for exotic receiver kinds, a +/// GC-heap `StringHeader` allocation for the prototype-chain probe, a +/// process-global `RwLock` read and two SipHash lookups. For `shape.area()` +/// that is four heap allocations and a lock around a single multiply. +/// +/// # Why a cache hit is sound +/// +/// An [`obj_dispatch_ic`](crate::object::class_registry::obj_dispatch_ic_lookup) +/// entry exists ONLY because an earlier call with this exact +/// `(class_id, method_name_ptr)` ran the entire tower and fell through to the +/// vtable arm. That is the proof that no *name-keyed* or *class-keyed* probe in +/// the tower claims this pair. +/// +/// Everything the tower decides per RECEIVER rather than per (class, name) is +/// re-established here, on every hit: +/// +/// * the value is a NaN-boxed pointer to a real heap object above the handle +/// band (excludes every small-handle registry receiver, and every primitive); +/// * its GC type is `GC_TYPE_OBJECT` and its `object_type` is +/// `OBJECT_TYPE_REGULAR` (excludes errors, arrays, maps, buffers, regexes, +/// closures — each of which the tower routes elsewhere); +/// * `class_id` matches the cache key; +/// * `meta` is null, so the object carries no `Object.setPrototypeOf` override, +/// no per-key descriptor state, and no exotic-kind tag — this is *stricter* +/// than the tower, which tolerates a meta record and resolves through it; +/// * no OWN key equals the method name, using the same byte comparison the +/// tower's field scan uses (an own field shadows the vtable); +/// * no static prototype is recorded for the address, so the tower's +/// `resolve_inherited_field` probe would have found nothing to shadow with. +/// +/// A miss (`None`) is always safe: the caller falls through to the full tower. +/// The receiver-shape predicate `G` shared by the fast path and by the sites +/// that are allowed to populate its cache. +/// +/// Returns the receiver's `class_id` when `object` is an ORDINARY heap +/// instance of a user class: everything the dispatch tower decides per RECEIVER +/// rather than per (class, name) is pinned here, so two receivers that both +/// satisfy `G` with the same class id and method name provably reach the same +/// resolution. +/// +/// * NaN-boxed pointer above the handle band — excludes every small-handle +/// registry receiver (timers, sockets, zlib streams, TextDecoder, …) and +/// every primitive; +/// * `GC_TYPE_OBJECT` + `OBJECT_TYPE_REGULAR` — excludes arrays, strings, +/// errors, maps, sets, regexes, closures, each of which the tower routes to +/// its own dispatcher; +/// * not a registered `Buffer` and not a typed array — the two address-keyed +/// probes the tower runs ahead of the class walk that a `GC_TYPE_OBJECT` +/// receiver could in principle also answer. Both are latched (#7755), so in +/// a program using neither this is two atomic loads; +/// * `meta` null — no `Object.setPrototypeOf` override, no per-key descriptor +/// state, no exotic-kind tag. STRICTER than the tower, which resolves +/// through a meta record; +/// * no OWN key equal to the method name (an own field shadows the vtable), +/// using the tower's own byte comparison; +/// * no recorded static prototype for the address, so the tower's +/// `resolve_inherited_field` probe had nothing to shadow with. +#[inline] +unsafe fn class_vtable_fast_guard(object: f64, method_bytes: &[u8]) -> Option<(usize, u32)> { + let bits = object.to_bits(); + if (bits >> 48) != (crate::value::POINTER_TAG >> 48) { + return None; + } + let obj_addr = (bits & crate::value::POINTER_MASK) as usize; + if !crate::value::addr_class::is_above_handle_band(obj_addr) { + return None; + } + // `gc_pointer_and_type_from_value` — NOT a bare `obj - GC_HEADER_SIZE` + // read. Buffers, ArrayBuffers, typed arrays, Sets, Maps, RegExps, Symbols + // and AsyncResource handles are raw allocations with no `GcHeader` at that + // offset, so reading one directly loads foreign allocator bytes that can + // and do coincidentally equal a real GC type (see `handle_methods.rs`'s + // buffer comment, and #5625 where a typed array's stale bytes matched + // `GC_TYPE_TEMPORAL`). This helper screens every one of those registries + // first — and it is the same screen the tower's own object-pointer + // resolution uses, so the fast path cannot classify a receiver differently + // from the code it is short-circuiting. + let (ptr, gc_type) = gc_pointer_and_type_from_value(object)?; + if gc_type != crate::gc::GC_TYPE_OBJECT || ptr as usize != obj_addr { + return None; + } + // `meta_capable_object` rather than a bare header read: it is the + // classifier `may_have_descriptor_entry` and `object_static_prototype` use, + // so a `Some` here means both of those answer authoritatively from the meta + // slot rather than falling back to a conservative `true`. + let obj = super::prototype_chain::meta_capable_object(obj_addr)?; + if (*obj).object_type != crate::error::OBJECT_TYPE_REGULAR { + return None; + } + // Null `meta` on a meta-capable object is what rules out BOTH a per-instance + // `[[Prototype]]` override AND any own descriptor entry — including an + // accessor installed on THIS instance for THIS name + // (`Object.defineProperty(instance, "m", { get() {…} })`), which would make + // the tower invoke the getter and call its result. That is a per-object + // divergence the class/name cache key cannot see, and + // `may_have_descriptor_entry` returns `false` for exactly this state. + if !(*obj).meta.is_null() { + return None; + } + let class_id = (*obj).class_id; + if class_id == 0 { + return None; + } + + // Own fields shadow vtable methods — same scan, same comparison, as the + // tower's field lookup. + let keys = (*obj).keys_array; + if !keys.is_null() { + let keys_ptr = keys as usize; + if (keys_ptr as u64) >> 48 != 0 || keys_ptr < 0x10000 { + return None; + } + let key_count = crate::array::js_array_length(keys) as usize; + if key_count > 65536 { + return None; + } + for i in 0..key_count { + let key_val = crate::array::js_array_get(keys, i as u32); + if crate::string::js_string_key_matches_bytes(key_val, method_bytes) { + return None; + } + } + } + + // A recorded prototype could carry a shadowing field; the tower consults it + // before the class walk, so a fast path may not. + if super::prototype_chain::object_static_prototype(obj_addr).is_some() { + return None; + } + + Some((obj_addr, class_id)) +} + +/// True for the method names whose tower probes depend on per-object state +/// [`class_vtable_fast_guard`] does not pin. +/// +/// * the `using` / `await using` disposal hooks read a SYMBOL-keyed own +/// property (`obj[Symbol.dispose]`), which the guard's string-`keys_array` +/// scan cannot see: two instances of one class can differ, so a resolution +/// cached from an instance without the symbol would route a later instance +/// with one straight past its custom disposer; +/// * the iterator helpers (`map`/`filter`/`take`/…) dispatch on whether the +/// receiver *is* an iterator. +#[inline] +pub(crate) fn method_name_is_fast_dispatch_ineligible(name: &str) -> bool { + matches!( + name, + "__perry_dispose__" | "__perry_async_dispose__" | "__perry_using_check__" + ) || crate::iterator_helpers::is_iterator_helper_method(name) +} + +#[inline] +unsafe fn try_class_vtable_fast_dispatch( + object: f64, + method_name_ptr: *const i8, + method_name_len: usize, + args_ptr: *const f64, + args_len: usize, +) -> Option { + if method_name_ptr.is_null() || method_name_len == 0 { + return None; + } + let method_bytes = std::slice::from_raw_parts(method_name_ptr as *const u8, method_name_len); + let (obj_addr, class_id) = class_vtable_fast_guard(object, method_bytes)?; + let (func_ptr, param_count, has_synthetic_arguments, has_rest) = + crate::object::class_registry::obj_dispatch_ic_lookup(class_id, method_bytes)?; + // A synthesized `arguments` object or a user rest param makes + // `call_vtable_method` allocate a JS array for that slot — a collection + // point. `obj_addr` is a bare local here (no handle scope: not creating one + // is most of the win), so keep the fast path free of any allocation + // between reading the receiver address and entering the callee. These two + // shapes are rare; the tower roots the receiver and handles them. + if has_synthetic_arguments || has_rest { + return None; + } + + // The recursion-depth guard is kept on the fast path. Skipping it would be + // a few instructions cheaper, but a cached dispatch is still a dispatch: + // mutually-recursive `a.m()`/`b.m()` chains reach the same unbounded stack + // growth this guard exists to stop, and once cached they would reach it + // WITHOUT ever being counted. + let _depth_guard = CallMethodDepthGuard::enter("")?; + + Some(crate::object::class_registry::call_vtable_method( + func_ptr, + obj_addr as i64, + args_ptr, + args_len, + param_count, + has_synthetic_arguments, + has_rest, + )) +} + +/// Record a class-walk resolution for the fast path, but only for a receiver +/// that satisfies [`class_vtable_fast_guard`] — the same predicate the fast +/// path re-checks — and only for a name whose tower probes are class/name +/// keyed. +/// +/// Callers are the two sites where the tower resolves an ORDINARY class +/// instance's method: the parent-chain walk in +/// `native_call_method::handle_methods` (which serves inherited methods, the +/// common case) and the tail vtable arm in `js_native_call_method`. +#[inline] +pub(crate) unsafe fn note_class_vtable_resolution( + object: f64, + method_name: &str, + func_ptr: usize, + param_count: u32, + has_synthetic_arguments: bool, + has_rest: bool, +) { + if method_name_is_fast_dispatch_ineligible(method_name) { + return; + } + let bytes = method_name.as_bytes(); + let Some((_, class_id)) = class_vtable_fast_guard(object, bytes) else { + return; + }; + crate::object::class_registry::obj_dispatch_ic_insert( + class_id, + bytes, + func_ptr, + param_count, + has_synthetic_arguments, + has_rest, + ); +} + unsafe fn call_primitive_closure_value( receiver: f64, value: JSValue, @@ -711,14 +947,27 @@ pub unsafe extern "C" fn js_native_call_method( args_ptr: *const f64, args_len: usize, ) -> f64 { - // Get the method name (parsed early for depth guard logging) - let method_name_owned = if method_name_ptr.is_null() || method_name_len == 0 { - String::new() + // #7769: the tower's own previously-computed answer for this + // (class_id, method_name) pair, when the receiver still satisfies every + // per-object precondition. See `try_class_vtable_fast_dispatch`. + if let Some(result) = + try_class_vtable_fast_dispatch(object, method_name_ptr, method_name_len, args_ptr, args_len) + { + return result; + } + + // Get the method name (parsed early for depth guard logging). + // + // #7769: borrowed, not owned. Codegen interns every method name as valid + // UTF-8 rodata, so the `Cow` is `Borrowed` on every real dispatch and the + // `into_owned()` this replaced was a `malloc`/`memcpy`/`free` per call. + let method_name_cow = if method_name_ptr.is_null() || method_name_len == 0 { + std::borrow::Cow::Borrowed("") } else { let bytes = std::slice::from_raw_parts(method_name_ptr as *const u8, method_name_len); - String::from_utf8_lossy(bytes).into_owned() + String::from_utf8_lossy(bytes) }; - let method_name = method_name_owned.as_str(); + let method_name: &str = &method_name_cow; let root_scope = crate::gc::RuntimeHandleScope::new(); let object_handle = root_scope.root_nanbox_f64(object); let original_args: Vec = if args_len > 0 && !args_ptr.is_null() { @@ -1558,14 +1807,32 @@ pub unsafe extern "C" fn js_native_call_method( if let Some(vtable) = reg.get(&class_id) { if let Some(entry) = vtable.methods.get(method_name) { let this_i64 = jsval().as_pointer::() as i64; + // #7769: reaching HERE is the proof that no + // name-keyed or class-keyed probe above claims + // this (class_id, method_name) — record it so the + // next call can go straight to the method. The + // per-receiver preconditions are re-checked on + // every hit; see `try_class_vtable_fast_dispatch`. + let func_ptr = entry.func_ptr; + let param_count = entry.param_count; + let has_synthetic_arguments = entry.has_synthetic_arguments; + let has_rest = entry.has_rest; + note_class_vtable_resolution( + object(), + method_name, + func_ptr, + param_count, + has_synthetic_arguments, + has_rest, + ); return call_vtable_method( - entry.func_ptr, + func_ptr, this_i64, args_ptr, args_len, - entry.param_count, - entry.has_synthetic_arguments, - entry.has_rest, + param_count, + has_synthetic_arguments, + has_rest, ); } } diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index 56c9ea6ae8..b10ce29939 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -1011,6 +1011,24 @@ pub(super) unsafe fn dispatch_handle( entry.has_synthetic_arguments, entry.has_rest, ); + // #7769: this walk — not the tail vtable + // arm of `js_native_call_method` — is where + // an INHERITED method resolves, and + // inherited methods are the common case in + // any real hierarchy (`class Square extends + // Rect` calling `Rect`'s `area`). Recording + // the outcome here is what lets the + // top-of-tower fast path serve them; the + // helper re-checks the receiver-shape + // predicate before storing anything. + super::note_class_vtable_resolution( + f64::from_bits(jsval.bits()), + method_name, + entry.func_ptr, + entry.param_count, + entry.has_synthetic_arguments, + entry.has_rest, + ); resolved_method = Some(ResolvedMethod::Vtable { func_ptr: entry.func_ptr, param_count: entry.param_count, diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 98fd844af4..c5f0f351e0 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -710,8 +710,18 @@ pub(crate) fn store_object_symbol_property_root( true } +/// Idle until a class declares a static Symbol-keyed member. +/// +/// `js_instanceof` consults `CLASS_STATIC_SYMBOLS` for a `Symbol.hasInstance` +/// override on EVERY evaluation, which meant a process-global `Mutex` plus a +/// SipHash probe of an empty map for every `x instanceof C` in a program that +/// never mentions a Symbol (#7769). +pub(crate) static CLASS_STATIC_SYMBOLS_LATCH: crate::registry_latch::RegistryLatch = + crate::registry_latch::RegistryLatch::new(); + pub(crate) fn store_class_static_symbol_root(class_id: u32, sym_key: usize, value_bits: u64) { note_symbol_key_installed(sym_key); + CLASS_STATIC_SYMBOLS_LATCH.arm(); { let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); if guard.is_none() { diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index e2d50b99dd..59a1a7d19d 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -561,7 +561,16 @@ pub unsafe extern "C" fn js_class_register_static_symbol(class_id: u32, sym: f64 /// Look up a static Symbol-keyed property on a class by class_id. /// Returns the stored value bits or `None` if no entry. Refs #420. +#[inline] pub fn class_static_symbol_lookup(class_id: u32, sym_f64: f64) -> Option { + if super::CLASS_STATIC_SYMBOLS_LATCH.is_idle() { + return None; + } + class_static_symbol_lookup_slow(class_id, sym_f64) +} + +#[inline(never)] +fn class_static_symbol_lookup_slow(class_id: u32, sym_f64: f64) -> Option { unsafe { let sym_key = sym_key_from_f64(sym_f64); if class_id == 0 || sym_key == 0 { diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index 478d6a6d30..4999fd5f5a 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -571,34 +571,13 @@ fn normalize_timer_delay(delay_value: f64) -> u64 { } fn set_timer_ref_state(id: i64, has_ref: bool) { + ref_states::TIMER_IDS_NONEMPTY.arm(); let mut slot = TIMER_REF_STATES.lock().unwrap(); slot.get_or_insert_with(TimerRefStates::default) .insert_bounded(id, has_ref, TIMER_REF_STATES_CAP); } -/// Whether `id` corresponds to a timer that was scheduled by this runtime -/// (active or already cleared). Used by the small-handle method/property -/// fast paths in `object/*.rs` and by `js_number_coerce` to decide whether -/// to apply Timeout-shaped semantics to a NaN-boxed small pointer. Without -/// this gate, any small handle (UI widget, drizzle, etc.) would accidentally -/// route through timer dispatch. -/// -/// Entries in `TIMER_REF_STATES` are inserted at schedule time and never -/// removed — clearing a timer marks it cleared in the queue but keeps the -/// id registered as "this was a timer" so post-clear `.hasRef()` / `+timer` -/// / `.unref()` still route through timer dispatch (Node keeps the -/// Timeout object alive after `clearTimeout` and methods still work). -pub fn is_known_timer_id(id: i64) -> bool { - if id <= 0 { - return false; - } - TIMER_REF_STATES - .lock() - .unwrap() - .as_ref() - .map(|s| s.states.contains_key(&id)) - .unwrap_or(false) -} +pub use ref_states::is_known_timer_id; fn throw_mock_timer_invalid_state(message: &str) -> ! { let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); diff --git a/crates/perry-runtime/src/timer/ref_states.rs b/crates/perry-runtime/src/timer/ref_states.rs index a168ac6873..e82b54dcf7 100644 --- a/crates/perry-runtime/src/timer/ref_states.rs +++ b/crates/perry-runtime/src/timer/ref_states.rs @@ -73,3 +73,59 @@ mod tests { assert_eq!(s.states.get(&42).copied(), Some(true)); } } + +/// Idle until the program schedules its first timer. +/// +/// `is_known_timer_id` is consulted by the small-handle method/property fast +/// paths and by `js_number_coerce`, so a program that never calls `setTimeout` +/// was taking a process-global mutex on the GENERIC dispatch path — #7769 +/// measured it as `pthread_mutex_lock` under `dispatch_primitive` on a pure +/// class-hierarchy benchmark that schedules no timers at all. +/// +/// Armed by `set_timer_ref_state`, which runs before any id becomes +/// observable, per `registry_latch`'s ordering rule. +pub(crate) static TIMER_IDS_NONEMPTY: crate::registry_latch::RegistryLatch = + crate::registry_latch::RegistryLatch::new(); + +/// Whether `id` corresponds to a timer that was scheduled by this runtime +/// (active or already cleared). Used by the small-handle method/property +/// fast paths in `object/*.rs` and by `js_number_coerce` to decide whether +/// to apply Timeout-shaped semantics to a NaN-boxed small pointer. Without +/// this gate, any small handle (UI widget, drizzle, etc.) would accidentally +/// route through timer dispatch. +/// +/// Entries in `TIMER_REF_STATES` are inserted at schedule time and never +/// removed — clearing a timer marks it cleared in the queue but keeps the +/// id registered as "this was a timer" so post-clear `.hasRef()` / `+timer` +/// / `.unref()` still route through timer dispatch (Node keeps the +/// Timeout object alive after `clearTimeout` and methods still work). +#[inline] +pub fn is_known_timer_id(id: i64) -> bool { + if id <= 0 || TIMER_IDS_NONEMPTY.is_idle() { + return false; + } + is_known_timer_id_slow(id) +} + +#[inline(never)] +fn is_known_timer_id_slow(id: i64) -> bool { + super::TIMER_REF_STATES + .lock() + .unwrap() + .as_ref() + .map(|s| s.states.contains_key(&id)) + .unwrap_or(false) +} + +#[cfg(test)] +mod latch_tests { + /// The OFF state is the one every timer-free program takes, so it is the + /// one that must be asserted: an accidentally pre-armed latch would put the + /// mutex back on the dispatch path with nothing to notice. + #[test] + fn starts_idle_so_a_timer_free_program_pays_nothing() { + if super::TIMER_IDS_NONEMPTY.is_idle() { + assert!(!crate::timer::is_known_timer_id(1)); + } + } +} diff --git a/test-files/test_gap_7769_class_dispatch_shapes.ts b/test-files/test_gap_7769_class_dispatch_shapes.ts new file mode 100644 index 0000000000..5d80ce5816 --- /dev/null +++ b/test-files/test_gap_7769_class_dispatch_shapes.ts @@ -0,0 +1,159 @@ +// #7769: virtual dispatch, `instanceof` and the class parent chain stopped +// going through a lock + SipHash probe per hop (a dense parent mirror), and +// `js_native_call_method` grew a cache of its own resolution so an ordinary +// class instance skips the dispatch tower. +// +// Both changes touch class REGISTRATION and the precedence rules the tower +// encodes, so this pins the shapes CLAUDE.md flags as weak for native +// base-class subclassing — a fieldless subclass, a two-level indirect +// subclass, a class EXPRESSION (including one with an `extends`), and +// dispatch through a base-typed collection — plus the precedence rule the +// dispatch fast path must not break: an own field shadows a vtable method, +// including after that (class, method name) pair has already been cached. + +class Base { + x: number; + constructor(x: number) { + this.x = x; + } + kind(): string { + return "base"; + } + score(): number { + return this.x; + } +} + +// A fieldless subclass — no own state at all. +class Marker extends Base { + kind(): string { + return "marker"; + } +} + +// A two-level indirect subclass: Leaf -> Mid -> Base. +class Mid extends Base { + y: number; + constructor(x: number, y: number) { + super(x); + this.y = y; + } + kind(): string { + return "mid"; + } + score(): number { + return this.x + this.y; + } +} +class Leaf extends Mid { + kind(): string { + return "leaf"; + } +} + +// A subclass that overrides NOTHING — inherits both methods across two hops. +class Silent extends Mid {} + +// A class expression, and a class expression WITH an extends clause. +const Anon = class { + kind(): string { + return "anon"; + } + score(): number { + return 7; + } +}; +const AnonSub = class extends Base { + kind(): string { + return "anonsub"; + } +}; + +// ── 1. dispatch through a base-typed collection ── +const items: Base[] = [ + new Base(1), + new Marker(2), + new Mid(3, 4), + new Leaf(5, 6), + new Silent(7, 8), + new AnonSub(9), +]; +let kinds = ""; +let total = 0; +for (let i = 0; i < items.length; i++) { + kinds = kinds + items[i].kind() + ","; + total = total + items[i].score(); +} +console.log("1 kinds:", kinds); +console.log("1 total:", total); + +// Repeat the whole loop so every call site runs both cold (tower) and warm +// (cached resolution) — a cache that answered a later iteration differently +// would show up right here. +let kinds2 = ""; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < items.length; i++) kinds2 = kinds2 + items[i].kind(); +} +console.log("2 warm kinds stable:", kinds2 === (kinds.split(",").join("")).repeat(3)); + +// ── 3. instanceof across the whole lattice ── +const leaf = new Leaf(1, 2); +const marker = new Marker(3); +console.log("3 leaf:", leaf instanceof Leaf, leaf instanceof Mid, leaf instanceof Base); +console.log("3 marker:", marker instanceof Marker, marker instanceof Base, marker instanceof Mid); +console.log("3 anon:", new Anon() instanceof Anon, new AnonSub(1) instanceof Base); +console.log("3 negatives:", leaf instanceof Marker, marker instanceof Leaf); + +// ── 4. an OWN field shadows the vtable method ── +// The dispatch fast path re-scans own keys precisely so this keeps working +// after the same (class, method name) pair has been resolved to the vtable. +const shadowed: any = new Base(10); +console.log("4 before shadow:", shadowed.kind()); +shadowed.kind = () => "own-field"; +console.log("4 after shadow:", shadowed.kind()); +const fresh: any = new Base(11); +console.log("4 sibling unaffected:", fresh.kind()); + +// NOTE — two shapes deliberately NOT asserted here, because Perry already +// diverges from Node on them at this change's merge-base (verified by running +// this file's earlier draft against a binary built from `origin/main`, which +// produced the identical wrong answers): +// +// * `Class.prototype.m = fn` after the first dispatch of `m` still resolves +// to the vtable method (Node: the assigned one); +// * `Object.setPrototypeOf(instance, donor)` does not redirect an already +// dispatched method on that instance (Node: it does). +// +// Both are upstream of the dispatch cache: the fast path's guard REJECTS a +// receiver with a non-null `meta` record (which `setPrototypeOf` installs) and +// prototype surgery now bumps `VTABLE_GEN`, so neither is reached from cache — +// the tower produces these answers on its own. Asserting them here would make +// this file a permanent gap failure and hide the regressions it exists to +// catch, so they are left to whoever fixes the tower. + +// ── 7. super() chains and statics still resolve up the (now dense) chain ── +class SBase { + static made: number = 0; + constructor() { + SBase.made = SBase.made + 1; + } + static describe(): string { + return "sbase"; + } +} +class SMid extends SBase {} +class SLeaf extends SMid {} +new SLeaf(); +new SMid(); +new SBase(); +console.log("7 statics:", SBase.made, SLeaf.describe(), SMid.describe()); + +// ── 8. a computed method name reaches the same dispatch ── +// This is the path that materialises the method name into a runtime string +// rather than a rodata constant, so it exercises the cache's content keying. +const names = ["kind", "score"]; +let computed = ""; +for (let i = 0; i < items.length; i++) { + computed = computed + String((items[i] as any)[names[i % 2]]()) + "|"; +} +console.log("8 computed:", computed); diff --git a/test-files/test_issue_7769_thread_class_dispatch.ts b/test-files/test_issue_7769_thread_class_dispatch.ts new file mode 100644 index 0000000000..29899b84dc --- /dev/null +++ b/test-files/test_issue_7769_thread_class_dispatch.ts @@ -0,0 +1,87 @@ +// #7769: the class parent chain moved from a process-global `RwLock` +// to a dense atomic mirror, and virtual dispatch grew a THREAD-LOCAL cache of +// the dispatch tower's resolution. +// +// `perry/thread` runs real OS threads with independent arenas, so both need to +// stay correct off the main thread: the parent mirror is shared and must be +// visible to a worker (an unregistered-looking chain would break `instanceof` +// and `super()`), while each worker starts with an EMPTY dispatch cache and +// must populate it from its own tower run rather than inheriting one. +// +// perry-only (`perry/thread` has no Node equivalent), so this is an +// `test_issue_*` behavioural test, not a byte-for-byte gap test. +import { parallelMap, spawn } from "perry/thread"; + +class Shape { + size: number; + constructor(size: number) { + this.size = size; + } + area(): number { + return this.size; + } + name(): string { + return "shape"; + } +} +class Box extends Shape { + area(): number { + return this.size * this.size; + } + name(): string { + return "box"; + } +} +// The fieldless / indirect shapes CLAUDE.md flags as weak. +class Marker extends Shape {} +class Cube extends Box { + area(): number { + return this.size * this.size * this.size; + } +} + +function describe(n: number): string { + const shapes: Shape[] = [ + new Shape(n), + new Box(n), + new Marker(n), + new Cube(n), + ]; + let out = ""; + for (let i = 0; i < shapes.length; i++) { + const s = shapes[i]; + out = + out + + s.name() + + ":" + + s.area() + + ":" + + (s instanceof Box ? "B" : "-") + + (s instanceof Shape ? "S" : "-") + + " "; + } + return out.trim(); +} + +// Main thread first, so the dispatch cache and the parent mirror are already +// warm when the workers start — a worker that wrongly READ the main thread's +// cache, or that failed to see the parent edges, diverges from this string. +const expected = describe(3); +console.log("main:", expected); + +// parallelMap: many workers, each building and dispatching its own instances. +const mapped = parallelMap([3, 3, 3, 3, 3, 3, 3, 3], (n: number): string => + describe(n), +); +let allMatch = true; +for (let i = 0; i < mapped.length; i++) { + if (mapped[i] !== expected) allMatch = false; +} +console.log("parallelMap count:", mapped.length, "allMatch:", allMatch); + +// spawn: a single background OS thread. +const spawned = await spawn((): string => describe(3)); +console.log("spawn:", spawned, "match:", spawned === expected); + +// The main thread must still be correct after the workers have run. +console.log("main again:", describe(3) === expected); From 6bad8b4b7f1c593290be2131924e614d4f49c5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 17:36:16 +0200 Subject: [PATCH 2/3] chore: bump version to 0.5.1452 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ea1f44c41..c6b6886c63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1451 +**Current Version:** 0.5.1452 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 29a1920a1e..7a6d59d8c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1451" +version = "0.5.1452" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1451" +version = "0.5.1452" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1451" +version = "0.5.1452" [[package]] name = "perry-ui-tvos" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1451" +version = "0.5.1452" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index e6209cec78..cb34520397 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1451" +version = "0.5.1452" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" From 5952ad146c41dcd4928aac7e0707ce5b6dfab2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 17:41:37 +0200 Subject: [PATCH 3/3] fix(gc): the dispatch cache's keys-array screen uses the band predicate, not a bare floor The addr-class ratchet caught the new site: 0x10000 sits below the fetch/zlib/proxy handle bands, so a handle id would have been dereferenced as a keys array (#7531/#7709's class). Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/object/native_call_method.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index f4d79a34df..f0d43dae4e 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -149,7 +149,11 @@ unsafe fn class_vtable_fast_guard(object: f64, method_bytes: &[u8]) -> Option<(u let keys = (*obj).keys_array; if !keys.is_null() { let keys_ptr = keys as usize; - if (keys_ptr as u64) >> 48 != 0 || keys_ptr < 0x10000 { + // Band predicate, not a bare floor (#7531/#7709): the 0x10000 floor this + // replaced sits below the fetch/zlib/proxy handle bands, so a handle id + // would have been dereferenced as a keys array. + if (keys_ptr as u64) >> 48 != 0 || !crate::value::addr_class::is_above_handle_band(keys_ptr) + { return None; } let key_count = crate::array::js_array_length(keys) as usize;