From e450e48d6e75a1a4efee6cf10209298d58f9d003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 16:11:38 +0200 Subject: [PATCH 1/2] perf(runtime): make an ordinary object's prototype a fact of its shape A ShapeId already names its receivers' prototype identity (`proto_id`: a prototype serial, MIXED, UNIQUE, NULL, CLASS or PER_OBJECT), but every reader went back to the per-instance `ObjectMeta.prototype` for the object itself, so each `new F()` instance allocated a 152-byte meta record only to hold it. The way back from the identity to the object is now one word per prototype identity (object/shapes_prototype.rs): stable pages indexed by serial (plain and MIXED identities share it) and by UNIQUE number, owned by the agent's shape slab and published to a thread-local directory. The ShapeRecord stays 64 bytes. - Writer: the one prototype funnel (`object_set_static_prototype_impl`) computes the identity, writes its word, and moves the receiver to the shape with that identity. A class-default link on a meta-less function-constructor instance (synthetic class) allocates no meta record; any other receiver already has one (link flags, PER_OBJECT identity) and gets the same bits there. #10507's birth replay no longer allocates meta and validates the birth ShapeId's facts instead of pinning the id. - Readers: `shapes::object_prototype_word` (meta word if recorded, else the identity's word for a synthetic-class receiver, else nothing; null from the identity, born-null cells unchanged) behind `object_static_prototype`, `object_proto_id`, native_get, method_site, prop_plan, timer handles, the typed-feedback guard and the setter site. - GC: the identity word is a traced edge of its carriers, beside the keys edge, in the generic walk and the copying drain's plain-object plan; a full trace emits it once per identity. A minor also roots every word naming a young object; rewrite passes repair every word; a word whose prototype died is cleared by a dead-owner prune. Links shade the prototype for an incremental mark. Worker seeds skip identities with a word. - Fixes on the way: `Object.create(null) instanceof Object` was true; a class instance re-parented with setPrototypeOf stayed `instanceof` its class; `Object.create(fn) instanceof Function` was false; a re-parented `Object.create(null)` object kept a null-prototype shape identity. - Test repair: test_object_meta_null_prototype_survives_full_gc_on_live_owner never pushed a shadow frame, so its owner was not rooted and it read freed memory. `new F()` 1,898 -> 1,165 instructions per construction. Refs #10507 --- changelog.d/PENDING-prototype-in-shape.md | 13 + crates/perry-runtime/src/gc/barrier_store.rs | 12 + .../src/gc/copying_object_scan.rs | 17 +- crates/perry-runtime/src/gc/dead_owner.rs | 10 + crates/perry-runtime/src/gc/full_trace.rs | 2 + .../perry-runtime/src/gc/layout_slot_visit.rs | 10 + crates/perry-runtime/src/gc/mod.rs | 3 + .../meta_and_shape_records.rs | 75 +++- .../construct/compiled_function.rs | 60 ++- .../construct/compiled_function_tests.rs | 9 +- crates/perry-runtime/src/object/gc_slots.rs | 14 + .../src/object/instanceof/static_dispatch.rs | 39 +- .../perry-runtime/src/object/method_site.rs | 6 +- crates/perry-runtime/src/object/mod.rs | 4 +- crates/perry-runtime/src/object/native_get.rs | 5 +- crates/perry-runtime/src/object/prop_plan.rs | 7 +- .../src/object/proto_validity.rs | 18 + .../src/object/prototype_chain.rs | 174 ++++++--- crates/perry-runtime/src/object/shapes.rs | 143 ++++++- .../src/object/shapes_prototype.rs | 356 ++++++++++++++++++ .../src/object/shapes_prototype_tests.rs | 86 +++++ .../src/object/shapes_slot_list.rs | 8 +- .../perry-runtime/src/object/shapes_store.rs | 6 + .../src/object/shapes_store_tests.rs | 2 + .../src/object/shapes_worker_seed.rs | 4 + .../src/proxy/put_value/setter_site.rs | 6 +- .../perry-runtime/src/timer/handle_object.rs | 5 +- .../src/typed_feedback/guards.rs | 6 +- scripts/gc_runtime_root_holders.json | 11 + test-files/test_gap_prototype_in_shape.ts | 133 +++++++ ...test_issue_protoshape_thread_prototypes.ts | 30 ++ 31 files changed, 1129 insertions(+), 145 deletions(-) create mode 100644 changelog.d/PENDING-prototype-in-shape.md create mode 100644 crates/perry-runtime/src/object/shapes_prototype.rs create mode 100644 crates/perry-runtime/src/object/shapes_prototype_tests.rs create mode 100644 test-files/test_gap_prototype_in_shape.ts create mode 100644 test-files/test_issue_protoshape_thread_prototypes.ts diff --git a/changelog.d/PENDING-prototype-in-shape.md b/changelog.d/PENDING-prototype-in-shape.md new file mode 100644 index 0000000000..42e0d6ddd0 --- /dev/null +++ b/changelog.d/PENDING-prototype-in-shape.md @@ -0,0 +1,13 @@ +An ordinary object's prototype is now a fact of its shape (Refs #10507). A +ShapeId already names its receivers' prototype identity, and that identity now +leads back to the prototype through one word per prototype. So `new F()`, +`Object.getPrototypeOf`, inherited reads, `instanceof` and the method and +accessor sites read a function-constructor instance's prototype from its +ShapeId, and the instance carries no per-instance metadata record. `new F()` +drops from ~1,900 to ~1,165 instructions and 152 bytes per instance. The word +is traced through the receivers that carry the identity, like a shape's key +list, so a prototype stays alive exactly as long as something reaches it. +Also fixed: `Object.create(null) instanceof Object` was true; a class instance +re-parented with `Object.setPrototypeOf` was still `instanceof` its class; +`Object.create(fn) instanceof Function` was false; an `Object.create(null)` +object re-parented with `Object.setPrototypeOf` kept a null-prototype shape. diff --git a/crates/perry-runtime/src/gc/barrier_store.rs b/crates/perry-runtime/src/gc/barrier_store.rs index 9c7025dc57..2a5a146504 100644 --- a/crates/perry-runtime/src/gc/barrier_store.rs +++ b/crates/perry-runtime/src/gc/barrier_store.rs @@ -191,6 +191,18 @@ pub(crate) fn runtime_store_jsvalue_slot( runtime_write_barrier_slot(parent_user, slot_addr, value_bits); } +/// Shade `value_bits` for an in-progress incremental mark: an edge that now +/// lives outside the heap on behalf of an owner the mark may already have +/// traced (a shape record's [[Prototype]] word, `object::shapes_prototype`). +/// The minor-collection half of such an edge is the shape table's +/// old-carrier gate, so no remembered-set entry is recorded. +pub(crate) fn runtime_shade_external_edge(value_bits: u64) { + if barrier_scalar_child_skips(value_bits) { + return; + } + let _ = incremental_mark_barrier_value(value_bits); +} + pub(crate) fn runtime_write_barrier_external_slot( parent_addr: usize, slot_addr: usize, diff --git a/crates/perry-runtime/src/gc/copying_object_scan.rs b/crates/perry-runtime/src/gc/copying_object_scan.rs index 2625238627..99de7c6fdb 100644 --- a/crates/perry-runtime/src/gc/copying_object_scan.rs +++ b/crates/perry-runtime/src/gc/copying_object_scan.rs @@ -31,11 +31,12 @@ use super::copying_parent_facts::{weak_holder_fact, ParentRemembering}; use super::*; /// The slots, in visit order, the generic walk's layout arm hands the drain for -/// this object: the keys edge and the meta record (null when absent), then the -/// payload slots its selection names. Iterated, not stored. +/// this object: the keys edge, the shape's prototype edge and the meta record +/// (null when absent), then the payload slots its selection names. Iterated, +/// not stored. #[derive(Clone)] struct PlainObjectPlan { - prefix: [*mut u64; 2], + prefix: [*mut u64; 3], next_prefix: usize, payload: HeapSlotRange, walk: PayloadWalk, @@ -59,7 +60,7 @@ enum PayloadWalk { impl PlainObjectPlan { #[inline(always)] unsafe fn next_slot(&mut self) -> Option<*mut u64> { - while self.next_prefix < 2 { + while self.next_prefix < 3 { let slot = self.prefix[self.next_prefix]; self.next_prefix += 1; if !slot.is_null() { @@ -131,8 +132,8 @@ unsafe fn plain_object_plan(header: *mut GcHeader) -> PlainObjectPlan { // `gc_child_slots` returns the EMPTY iterator: no shape, so no keys // edge and no carrier note, no meta edge, no payload. return PlainObjectPlan { - prefix: [std::ptr::null_mut(); 2], - next_prefix: 2, + prefix: [std::ptr::null_mut(); 3], + next_prefix: 3, payload: HeapSlotRange::new(std::ptr::null_mut(), 0), walk: PayloadWalk::Word(0), }; @@ -182,11 +183,13 @@ unsafe fn plain_object_plan(header: *mut GcHeader) -> PlainObjectPlan { crate::object::shapes::note_old_generation_carrier(shape); } let keys_edge = crate::object::gc_shape_keys_edge_slot(shape); + let prototype_edge = crate::object::gc_shape_prototype_edge_slot(shape, false); // Visit order of the generic walk: prefix (none for objects), keys edge, - // meta, meta2 (none), payload. + // prototype edge, meta, meta2 (none), payload. PlainObjectPlan { prefix: [ keys_edge.unwrap_or(std::ptr::null_mut()), + prototype_edge.unwrap_or(std::ptr::null_mut()), meta.unwrap_or(std::ptr::null_mut()), ], next_prefix: 0, diff --git a/crates/perry-runtime/src/gc/dead_owner.rs b/crates/perry-runtime/src/gc/dead_owner.rs index 0f7b3b07f8..102e352194 100644 --- a/crates/perry-runtime/src/gc/dead_owner.rs +++ b/crates/perry-runtime/src/gc/dead_owner.rs @@ -413,6 +413,16 @@ pub(super) const DEAD_KEY_PRUNES: &[DeadKeyPrune] = &[ prune: crate::object::shapes::prune_dead_shape_keys, young_prune: Some(crate::object::shapes::prune_dead_shape_keys_young), }, + // A prototype identity's word is traced through its carriers; a word whose + // prototype died has none left, so it is cleared. + DeadKeyPrune { + table: "state().shapes prototype words + identity index", + owner: DeadKeyOwner::Any, + prune: crate::object::shapes::prune_dead_shape_prototypes, + // A minor roots every young word (`scan_shape_prototype_words_mut`), + // so only a full trace can find a word's prototype dead. + young_prune: None, + }, // #10868 step 2.5 stage 1b: the canonical keys trie holds its arrays // WEAKLY, so a node whose array did not survive has to be reaped here or // the next probe dereferences freed memory. Runs after the shape prune diff --git a/crates/perry-runtime/src/gc/full_trace.rs b/crates/perry-runtime/src/gc/full_trace.rs index cff68729f8..c2f126754e 100644 --- a/crates/perry-runtime/src/gc/full_trace.rs +++ b/crates/perry-runtime/src/gc/full_trace.rs @@ -16,6 +16,8 @@ pub(crate) fn begin_full_trace() { FULL_TRACE_ACTIVE.with(|active| { assert!(!active.replace(true), "full trace already active"); }); + // The prototype identity words emit their carrier edge once per trace. + crate::object::shapes::note_full_trace_begin(); crate::proxy::gc_begin_full_trace(); if let Some(hook) = FETCH_TRACE.with(Cell::get) { FETCH_TRACE_ARMED.with(|armed| armed.set(true)); diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 3e81957403..4c08b6b920 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -116,6 +116,11 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors_inline( // `PERRY_GC_VERIFY_EVACUATION` is what established the second half is // needed: without it the verifier aborts on a `slot_page_ever_dirty=false` // old→young edge through this word. + let shape_prototype_edge = if (*header).obj_type == GC_TYPE_OBJECT { + crate::object::gc_shape_prototype_edge_slot(child_slots.object_shape, full_trace_active()) + } else { + None + }; let shape_keys_edge = if (*header).obj_type == GC_TYPE_OBJECT { // #9726: unlike the minor-rooting gate below, full-trace descriptor // liveness is generation-blind. Every reachable shaped receiver must @@ -144,6 +149,11 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors_inline( if let Some(slot) = shape_keys_edge { visit(fixed_slot(slot).with_layout(HeapChildSlotReadKind::Prefix)); } + // The receiver's [[Prototype]] when its shape names it: the identity's + // shared word, marked through like the keys word. + if let Some(slot) = shape_prototype_edge { + visit(fixed_slot(slot).with_layout(HeapChildSlotReadKind::Prefix)); + } if let Some(slot) = child_slots.take_meta_child_slot() { visit(fixed_slot(slot).with_layout(HeapChildSlotReadKind::Prefix)); } diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index ebe3a95cf4..4160f427d3 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1036,6 +1036,9 @@ pub fn gc_init() { // ordered-keys slot; this scanner only follows existing forwarding records // for descriptors and the pointer-keyed slot accelerator after evacuation. reg_scanner!(crate::object::shapes::scan_shape_table_rekey_mut); + // The shape records' [[Prototype]] words and their identity index are + // strong roots (object::shapes_prototype). + reg_scanner!(crate::object::shapes::scan_shape_prototype_words_mut); reg_scanner!(crate::proxy::scan_proxy_roots_mut); // Object/string-valued `err. = v` user props live as raw bits in reg_scanner!(exception_mutable_root_scanner); diff --git a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables/meta_and_shape_records.rs b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables/meta_and_shape_records.rs index 4eff9b4c6c..a59d9ddf42 100644 --- a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables/meta_and_shape_records.rs +++ b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables/meta_and_shape_records.rs @@ -168,6 +168,64 @@ fn test_object_meta_prototype_survives_copied_minor_move() { js_shadow_slot_set(1, 0); } +/// The shape names the prototype (`object::shapes_prototype`): a receiver +/// linked by a class-default link (`new F()`) has no meta record, and its +/// prototype is reachable ONLY through its shape record's `prototype` word. +/// A copied minor must keep that prototype alive through the carrier's edge +/// and rewrite the shared word to the moved prototype. +#[test] +fn test_shape_prototype_word_survives_copied_minor_move_through_its_carrier() { + let _guard = CopyingNurseryTestGuard::new(2); + + let (owner, _) = unsafe { alloc_nursery_test_object(0) }; + // A function constructor's instance (synthetic class id): the one + // receiver a class-default link leaves without a meta record. + unsafe { + (*owner).class_id = crate::object::shapes::SYNTHETIC_CLASS_ID_BASE + 0x52; + } + let (proto, proto_fields) = unsafe { alloc_nursery_test_object(1) }; + unsafe { *proto_fields = 42.0f64.to_bits() }; + let old_owner = owner as usize; + let old_proto = proto as usize; + crate::object::prototype_chain::object_link_class_default_prototype( + old_owner, + ptr_bits(old_proto), + ); + assert!( + unsafe { (*owner).meta }.is_null(), + "test premise: a class-default link allocates no meta record" + ); + let stamp = unsafe { crate::object::shapes::object_shape_stamp(owner) }; + assert_eq!( + crate::object::shapes::shape_prototype_word(stamp), + ptr_bits(old_proto), + "test premise: the shape record holds the prototype" + ); + // Only the owner is rooted: the prototype lives through the shape edge. + js_shadow_slot_set(0, ptr_bits(old_owner)); + + let _ = gc_collect_minor(); + + let new_owner = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!(new_owner, old_owner, "test premise: the owner must move"); + let recorded = crate::object::prototype_chain::object_static_prototype(new_owner) + .expect("the moved owner must still resolve its prototype through its shape"); + let new_proto = (recorded & POINTER_MASK) as usize; + assert_ne!( + new_proto, old_proto, + "the prototype must be evacuated and the shape's word rewritten" + ); + assert_eq!( + unsafe { + *((new_proto + std::mem::size_of::()) as *const u64) + }, + 42.0f64.to_bits(), + "the rewritten word names the live, moved prototype" + ); + + js_shadow_slot_set(0, 0); +} + /// A class-evaluation object may be reachable only through an instance's /// hidden ObjectMeta brand. The edge must retain and rewrite that class object /// when a copied minor moves the owner, its metadata, and the brand together. @@ -661,6 +719,10 @@ fn test_deferred_shape_slot_enumeration_survives_descriptor_table_reallocation() #[test] fn test_object_meta_null_prototype_survives_full_gc_on_live_owner() { let _guard = GcTestIsolationGuard::new(); + // The owner is rooted through a shadow frame of its own: without one the + // slot store roots nothing, the owner dies, and the read below would + // examine freed memory and pass for the wrong reason. + let frame = js_shadow_frame_push(1); let (owner, _) = unsafe { alloc_nursery_test_object(0) }; let addr = owner as usize; @@ -669,11 +731,18 @@ fn test_object_meta_null_prototype_survives_full_gc_on_live_owner() { full_gc(); + let live = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_eq!(live, addr, "test premise: a full mark-sweep does not move"); assert_eq!( - crate::object::prototype_chain::object_static_prototype(addr), + crate::object::prototype_chain::object_static_prototype(live), Some(crate::value::TAG_NULL), - "a live (rooted) owner's meta record — and its explicit-null \ - prototype — must survive a full collection" + "a live (rooted) owner's explicit-null prototype (a fact of its \ + shape) must survive a full collection" + ); + assert!( + !unsafe { (*(live as *const crate::object::ObjectHeader)).meta }.is_null(), + "the runtime-wiring link's divergence flag keeps its meta record alive" ); js_shadow_slot_set(0, 0); + js_shadow_frame_pop(frame); } diff --git a/crates/perry-runtime/src/object/class_registry/construct/compiled_function.rs b/crates/perry-runtime/src/object/class_registry/construct/compiled_function.rs index 967a2ad056..d3c538fdf7 100644 --- a/crates/perry-runtime/src/object/class_registry/construct/compiled_function.rs +++ b/crates/perry-runtime/src/object/class_registry/construct/compiled_function.rs @@ -12,10 +12,11 @@ //! class id and birth ShapeId are the prototype object's birth record //! (`ObjectMeta::instance_birth`), minted on the first construction by the //! ordinary allocate-then-link sequence and replayed afterwards — the same -//! class id, the same ShapeId (its `proto_id` is the prototype's serial) and -//! the same `meta.prototype` that sequence produces, with no hash lookup. A -//! reassigned `F.prototype` is read on the next construction; objects already -//! created keep the prototype their meta records. +//! class id and the same ShapeId (its `proto_id` is the prototype's serial, +//! its record's `prototype` word the prototype itself) that sequence +//! produces, with no hash lookup and no per-instance record. A reassigned +//! `F.prototype` is read on the next construction; objects already created +//! keep the prototype their shapes name. use super::*; use crate::closure::ClosureHeader; @@ -101,15 +102,21 @@ unsafe fn birth_record(proto: *const ObjectHeader) -> Option<(u32, u32, u32)> { // size the class has learned; a class that has since learned a larger // size gets a new record. let slots = crate::object::learned_inline_field_count(class_id); - (crate::object::shapes::shape_live_inline_slot_count_by_id(shape_id) == Some(slots)) + // The id is not pinned: the descriptor table may have retired it and + // handed the id to another shape. Replay it only while it still names + // the birth facts — keyless, generation 0, no holes, this prototype's + // identity (so its record's word IS `proto`) and the learned slots. + crate::object::shapes::shape_is_keyless_birth(shape_id, (*meta).proto_serial, slots) .then_some((class_id, shape_id, slots)) } /// Mint `proto`'s birth record from the first construction, which takes the /// ordinary sequence: an object of `F`'s synthetic class, linked to `proto` /// as its class-default prototype. The record is that class id and the -/// ShapeId the link left, pinned for the agent's life so the record can never -/// name a retired id. Returns the constructed object. +/// ShapeId the link left; [`birth_record`] re-validates the id's facts on +/// every replay, so a retired id is never replayed and a dead function's +/// prototype is not kept alive by a pinned shape. Returns the constructed +/// object. /// /// The class is the minting function's; a class whose registered prototype is /// later moved off `proto` clears the record @@ -134,9 +141,6 @@ unsafe fn mint_birth_record(func_value: f64, proto: *mut ObjectHeader) -> *mut O }); let shape_id = obj.with_mut_ptr::(|obj| crate::object::shapes::object_shape_stamp(obj)); - crate::object::shapes::note_external_shape_carrier( - crate::object::shapes::shape_descriptor_by_id(shape_id), - ); let meta = proto_handle .with_mut_ptr::(|proto| crate::object::object_meta_ensure(proto)); // GC_STORE_AUDIT(POINTER_FREE): a class id and a ShapeId, never a heap @@ -162,34 +166,16 @@ pub(crate) unsafe fn forget_birth_record_of_class(old: *mut ObjectHeader, class_ } } -/// An object born from `proto`'s record: class id and birth ShapeId stamped, -/// `meta.prototype` = `proto` (what the class-default link records). +/// An object born from `proto`'s record: class id and birth ShapeId stamped. +/// The ShapeId names `proto` (its record's `prototype` word), which is all +/// the class-default link records; no per-instance record is allocated. /// /// # Safety -/// `proto` is a live `ObjectHeader` marked as a prototype. -unsafe fn born_from_record( - proto: *mut ObjectHeader, - class_id: u32, - shape_id: u32, - slots: u32, -) -> *mut ObjectHeader { - let scope = crate::gc::RuntimeHandleScope::new(); - let proto_handle = scope.root_raw_mut_ptr(proto); - let obj = scope.root_raw_mut_ptr(crate::object::object_alloc_born(class_id, slots, shape_id)); - let meta = obj.with_mut_ptr::(|obj| crate::object::object_meta_ensure(obj)); - let proto_bits = proto_handle - .with_mut_ptr::(|proto| crate::value::js_nanbox_pointer(proto as i64)) - .to_bits(); - (*meta).prototype = proto_bits; - // GC_STORE_AUDIT(BARRIERED): meta-record prototype slot store — the - // record is an arena allocation, so the ordinary object-slot barrier - // applies (parent = the meta record), as in the class-default link. - crate::gc::runtime_write_barrier_slot( - meta as usize, - &(*meta).prototype as *const u64 as usize, - proto_bits, - ); - obj.get_raw_mut_ptr::() +/// `proto` is a live `ObjectHeader` marked as a prototype, and `shape_id` +/// passed [`birth_record`] for it. +#[inline] +unsafe fn born_from_record(class_id: u32, shape_id: u32, slots: u32) -> *mut ObjectHeader { + crate::object::object_alloc_born(class_id, slots, shape_id) } /// `new F(...args)` for an ordinary compiled function `F` (`closure`), or @@ -220,7 +206,7 @@ pub(super) unsafe fn construct_ordinary_compiled_function( None => return None, }; let obj = match birth_record(proto) { - Some((class_id, shape_id, slots)) => born_from_record(proto, class_id, shape_id, slots), + Some((class_id, shape_id, slots)) => born_from_record(class_id, shape_id, slots), None => mint_birth_record(func_handle.get_nanbox_f64(), proto), }; let instance = crate::value::js_nanbox_pointer(obj as i64); diff --git a/crates/perry-runtime/src/object/class_registry/construct/compiled_function_tests.rs b/crates/perry-runtime/src/object/class_registry/construct/compiled_function_tests.rs index 799bd8d822..32cc95c0e4 100644 --- a/crates/perry-runtime/src/object/class_registry/construct/compiled_function_tests.rs +++ b/crates/perry-runtime/src/object/class_registry/construct/compiled_function_tests.rs @@ -61,13 +61,14 @@ fn construction_is_born_from_the_prototype_birth_record() { synthetic_class_id_for_function(func), "a construction carries its function's class" ); - let second_meta = (*second).meta; + // The birth shape names the prototype; a replayed construction + // carries no per-instance record. assert!( - !second_meta.is_null(), - "the class-default link's meta record" + (*second).meta.is_null(), + "a replayed construction allocates no meta record" ); assert_eq!( - (*second_meta).prototype, + crate::object::shapes::object_prototype_word(second), crate::value::js_nanbox_pointer(proto as i64).to_bits() ); assert_eq!( diff --git a/crates/perry-runtime/src/object/gc_slots.rs b/crates/perry-runtime/src/object/gc_slots.rs index 0b07f01dbd..7afda84636 100644 --- a/crates/perry-runtime/src/object/gc_slots.rs +++ b/crates/perry-runtime/src/object/gc_slots.rs @@ -36,6 +36,20 @@ pub(crate) fn gc_shape_keys_edge_slot(record: Option) -> Some(record.keys_slot()) } +/// The AUTHORITATIVE [[Prototype]] edge of a traced receiver whose shape names +/// its prototype object (`shapes_prototype`): the shape record's own +/// `prototype` word, shared by every sibling exactly like the keys edge above +/// (a young carrier emits it; a minor also roots every word naming a young +/// object, which covers the old carriers it never traces). In a full trace +/// only the first carrier of each identity emits it (`identity_edge_slot`). +#[inline] +pub(crate) fn gc_shape_prototype_edge_slot( + record: Option, + full_trace: bool, +) -> Option<*mut u64> { + record?.prototype_slot(full_trace) +} + /// The object's inline field-slot range, given the receiver's shape record /// resolved once by the collector. pub(crate) unsafe fn gc_field_slot_range( diff --git a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs index ae5d7b664d..e78fa66690 100644 --- a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs @@ -170,11 +170,22 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // has `Function.prototype` in its prototype chain. Keep `CLASS_ID_FUNCTION` // in sync with perry-codegen/src/expr/instance_misc1.rs. if class_id == CLASS_ID_FUNCTION { - return if value_is_callable(value) { - true_val - } else { - false_val - }; + if value_is_callable(value) { + return true_val; + } + // An ordinary object whose recorded chain reaches a function + // (`Object.create(fn)`) has `Function.prototype` on it too. + let addr = value_addr(value); + if addr != 0 + && unsafe { crate::object::prototype_chain::meta_capable_object(addr) }.is_some() + && crate::object::prototype_chain::object_static_prototype(addr).is_some() + { + let function = js_get_global_this_builtin_value(b"Function".as_ptr(), 8); + if ordinary_has_instance_prototype_walk(value, function) { + return true_val; + } + } + return false_val; } if class_id == CLASS_ID_URL { let addr = value_addr(value); @@ -594,6 +605,24 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { } } } + // An ordinary object whose chain ends in null before it reaches + // `Object.prototype` (`Object.create(null)`) is not an instance. + // Only a cell born null, a receiver with a recorded prototype, or + // a program that ever replaced one can have such a chain. + let addr = jsval.as_pointer::() as usize; + if let Some(obj) = unsafe { crate::object::prototype_chain::meta_capable_object(addr) } + { + let born_null = + unsafe { crate::value::addr_class::try_read_gc_header(obj as usize) } + .is_some_and(|h| h._reserved & crate::gc::OBJ_FLAG_NULL_PROTO != 0); + if (born_null + || crate::object::prototype_chain::any_user_prototype_override() + || crate::object::prototype_chain::object_static_prototype(addr).is_some()) + && crate::object::prototype_chain::prototype_chain_ends_in_null_before_object_prototype(addr) + { + return false_val; + } + } // Covers every heap object, including a Date (now a NaN-boxed // `DateCell` pointer — #2089) and an Invalid Date. return true_val; diff --git a/crates/perry-runtime/src/object/method_site.rs b/crates/perry-runtime/src/object/method_site.rs index 47af3e6883..3649461637 100644 --- a/crates/perry-runtime/src/object/method_site.rs +++ b/crates/perry-runtime/src/object/method_site.rs @@ -1093,9 +1093,9 @@ unsafe fn prime_inherited( /// record's `[[Prototype]]`, else a synthetic class's (`Object.create`, an ES5 /// constructor) registered prototype. Null for a default builtin prototype. unsafe fn next_prototype(obj: *const ObjectHeader) -> *const ObjectHeader { - let meta = (*obj).meta; - if !meta.is_null() && (*meta).prototype != 0 { - let p = crate::value::JSValue::from_bits((*meta).prototype); + let recorded = crate::object::shapes::object_prototype_word(obj); + if recorded != 0 { + let p = crate::value::JSValue::from_bits(recorded); if !p.is_pointer() { return std::ptr::null(); } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index cd93e16efd..54d8f97bca 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -127,8 +127,8 @@ pub(crate) use field_get_set::scan_accessor_receiver_override_root_mut; mod field_set_by_name; mod gc_slots; pub(crate) use gc_slots::{ - gc_field_slot_range, gc_shape_keys_edge_slot, rebuild_array_layout_from_slots, - rebuild_object_field_layout, + gc_field_slot_range, gc_shape_keys_edge_slot, gc_shape_prototype_edge_slot, + rebuild_array_layout_from_slots, rebuild_object_field_layout, }; pub(crate) mod global_fetch; pub(crate) use global_fetch::scan_pending_fetch_signal_root_mut; diff --git a/crates/perry-runtime/src/object/native_get.rs b/crates/perry-runtime/src/object/native_get.rs index f5bcd7bb26..9255c7411e 100644 --- a/crates/perry-runtime/src/object/native_get.rs +++ b/crates/perry-runtime/src/object/native_get.rs @@ -140,8 +140,9 @@ pub(crate) unsafe fn try_data_get_bytes(receiver: JSValue, key: &[u8]) -> Option } else { return None; } - if !meta.is_null() && (*meta).prototype != 0 { - let prototype = JSValue::from_bits((*meta).prototype); + let recorded = crate::object::shapes::object_prototype_word(object); + if recorded != 0 { + let prototype = JSValue::from_bits(recorded); if !prototype.is_pointer() { return None; } diff --git a/crates/perry-runtime/src/object/prop_plan.rs b/crates/perry-runtime/src/object/prop_plan.rs index 9a5d82b69e..0f40023973 100644 --- a/crates/perry-runtime/src/object/prop_plan.rs +++ b/crates/perry-runtime/src/object/prop_plan.rs @@ -138,12 +138,7 @@ struct PlanEntry { /// `obj` is a live ordinary object. #[inline] pub(crate) unsafe fn receiver_proto_bits(obj: *const super::ObjectHeader) -> u64 { - let meta = (*obj).meta; - if meta.is_null() { - 0 - } else { - (*meta).prototype - } + super::shapes::object_prototype_word(obj) } // SAFETY: integer fields only; `key_ptr == 0` never matches an interned key, diff --git a/crates/perry-runtime/src/object/proto_validity.rs b/crates/perry-runtime/src/object/proto_validity.rs index 52c40114ab..2477875db4 100644 --- a/crates/perry-runtime/src/object/proto_validity.rs +++ b/crates/perry-runtime/src/object/proto_validity.rs @@ -161,6 +161,24 @@ pub(crate) unsafe fn mark_exotic_read_receiver(obj: usize) { ensure_meta_for_mark(obj, crate::object::OBJECT_META_FLAG_EXOTIC_READ_RECEIVER) }); if let Some(meta) = meta { + // The receiver is leaving every shape that can name its prototype + // (`PROTO_ID_PER_OBJECT`): its meta record becomes the authority, so + // copy the shape's word there first. + if (*meta).prototype == 0 { + let word = crate::object::shapes::shape_prototype_word( + crate::object::shapes::object_shape_stamp(object), + ); + if word != 0 { + (*meta).prototype = word; + // GC_STORE_AUDIT(BARRIERED): meta-record prototype slot store + // (parent = the meta record), as in the prototype funnel. + crate::gc::runtime_write_barrier_slot( + meta as usize, + &(*meta).prototype as *const u64 as usize, + word, + ); + } + } // GC_STORE_AUDIT(POINTER_FREE): scalar classification bit. (*meta).flags |= crate::object::OBJECT_META_FLAG_EXOTIC_READ_RECEIVER; // The flag makes the receiver's [[Prototype]] identity its own diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index b6a512fd02..22eca907a6 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -530,43 +530,87 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: // links cannot invalidate a proof about a previously allocated object. crate::array::invalidate_all_element_shapes(); } - // #6759 Phase B: shaped objects store the recorded prototype in their - // own meta record; only non-object owners fall through to the residual - // registry. + // A shaped object's [[Prototype]] is a fact of its SHAPE: the receiver + // moves to the shape naming its new prototype, and that shape record's + // `prototype` word is what every reader returns (`shapes_prototype`). + // Only a receiver whose shape answers nothing about it + // (`PROTO_ID_PER_OBJECT`: a native-module namespace, an exotic-read + // receiver) records it in its meta record. Non-object owners fall through + // to the residual registry. unsafe { if let Some(obj) = meta_capable_object(obj_ptr) { - // `object_meta_ensure` allocates and may evacuate the owner. Keep - // both the caller's pointer and the prototype rooted, then reload - // them before the stores below. let scope = crate::gc::RuntimeHandleScope::new(); let obj_handle = scope.root_raw_mut_ptr(obj); let proto_handle = scope.root_heap_word_u64(proto_bits); - let (meta, obj) = obj_handle.across_mut::(|| { - crate::object::object_meta_ensure(obj) - }); - let proto_bits = proto_handle.get_heap_word_u64(); - (*meta).prototype = proto_bits; + // The identity reads the receiver's class and classification and + // the prototype's serial — never this receiver's meta word — so + // it is taken before anything allocates. A prototype with no + // serial (a function, array or typed array) gets an identity of + // its own. Same predecessor + same prototype reaches the same + // shape, so construction shares shapes as before; a class-default + // link is not exempt (`F.prototype = other` then `new F()` must + // not leave old and new instances on one shape over two chains). + let proto_id = match prototype_serial { + Some(_) => crate::object::shapes::object_proto_id_for(obj, proto_bits), + None => crate::object::shapes::fresh_unique_proto_id(), + }; + // A receiver whose shape word cannot be written cannot move to a + // shape naming the prototype either: its meta record is the + // authority, as for a per-object identity. + let per_object = proto_id == crate::object::shapes::PROTO_ID_PER_OBJECT + || !crate::object::shapes::shape_word_is_writable(obj); + let mut link_flags = 0u64; if prototype_diverged { - (*meta).flags |= crate::object::OBJECT_META_FLAG_PROTO_DIVERGED; + link_flags |= crate::object::OBJECT_META_FLAG_PROTO_DIVERGED; } if user_override { - // Latch BEFORE the flag: a reader that observes the flag must - // already observe the latch (see `USER_PROTO_OVERRIDE_EVER`). - USER_PROTO_OVERRIDE_EVER.store(true, Ordering::Release); - (*meta).flags |= crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE; - crate::object::class_registry::class_prototype_relinked(obj); + link_flags |= crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE; } if link_kind == PrototypeLinkKind::ClassEvaluation { - (*meta).flags |= crate::object::OBJECT_META_FLAG_CLASS_EVALUATION_PROTO; + link_flags |= crate::object::OBJECT_META_FLAG_CLASS_EVALUATION_PROTO; } - // GC_STORE_AUDIT(BARRIERED): meta-record prototype slot store — - // the record is an arena allocation, so the ordinary object-slot - // barrier applies (parent = the meta record). - crate::gc::runtime_write_barrier_slot( - meta as usize, - &(*meta).prototype as *const u64 as usize, - proto_bits, - ); + let mut obj = obj; + // A class-default link on a meta-less instance of a function + // constructor (`new F()`, a synthetic class) needs no meta record + // at all: `shapes::object_prototype_word` reads such a receiver's + // prototype from its shape's identity word. Every other receiver + // has a record anyway — for the link's flags, a PER_OBJECT + // identity, or as the one-compare "nothing recorded" answer for a + // meta-less non-synthetic receiver — and that record's word is + // the cheaper read of the same prototype, written here beside the + // identity's word and never anywhere else. + if per_object + || link_flags != 0 + || !(*obj).meta.is_null() + || !crate::object::shapes::is_synthetic_class_id((*obj).class_id) + { + // `object_meta_ensure` allocates and may evacuate the owner. + let (meta, moved) = obj_handle.across_mut::(|| { + crate::object::object_meta_ensure(obj) + }); + obj = moved; + let word = proto_handle.get_heap_word_u64(); + (*meta).prototype = word; + if user_override { + // Latch BEFORE the flag: a reader that observes the flag + // must already observe the latch (see + // `USER_PROTO_OVERRIDE_EVER`). + USER_PROTO_OVERRIDE_EVER.store(true, Ordering::Release); + } + (*meta).flags |= link_flags; + if user_override { + crate::object::class_registry::class_prototype_relinked(obj); + } + // GC_STORE_AUDIT(BARRIERED): meta-record prototype slot store — + // the record is an arena allocation, so the ordinary + // object-slot barrier applies (parent = the meta record). + crate::gc::runtime_write_barrier_slot( + meta as usize, + &(*meta).prototype as *const u64 as usize, + word, + ); + } + let proto_bits = proto_handle.get_heap_word_u64(); #[cfg(feature = "shape-mint-diag")] if prototype_diverged { crate::object::shape_mint_census::note_proto_divergence( @@ -574,19 +618,10 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: proto_bits, ); } - // The [[Prototype]] is a SHAPE fact, for every link kind: the - // receiver moves to the shape naming its new prototype. A class- - // default link is not exempt — `F.prototype = other` followed by - // `new F()` otherwise leaves old and new instances on one shape - // over two chains. Same predecessor + same prototype reaches the - // same shape, so construction shares shapes as before. A - // prototype with no serial (a function, array or typed array) - // gets an identity of its own. - let proto_id = match prototype_serial { - Some(_) => crate::object::shapes::object_proto_id(obj), - None => crate::object::shapes::fresh_unique_proto_id(), - }; - crate::object::shapes::transition_object_shape_prototype(obj, proto_id); + crate::object::shapes::transition_object_shape_prototype(obj, proto_id, proto_bits); + // The receiver may already be traced by an incremental mark; its + // new edge lives in its shape record, outside the heap. + crate::gc::runtime_shade_external_edge(proto_bits); return; } } @@ -626,20 +661,14 @@ pub fn object_static_prototype(obj_ptr: usize) -> Option { if crate::hot_diag::receiver_repr_on() { crate::hot_diag::receiver_repr_note_decoded_pointer(obj_ptr); } - // #6759 Phase B: a shaped object answers from its own meta record — two - // dependent loads, no global latch, no mutex — and NEVER has a residual - // registry entry (the write path classifies identically), so a meta - // miss for a shaped object is authoritative. + // A shaped object answers from its shape record (or, for a per-object + // identity, its meta record) — no global latch, no mutex — and NEVER has + // a residual registry entry (the write path classifies identically), so + // a miss for a shaped object is authoritative. unsafe { if let Some(obj) = meta_capable_object(obj_ptr) { - let meta = (*obj).meta; - if !meta.is_null() { - let bits = (*meta).prototype; - if bits != 0 { - return Some(bits); - } - } - return None; + let bits = crate::object::shapes::object_prototype_word(obj); + return (bits != 0).then_some(bits); } } if !OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) { @@ -722,15 +751,36 @@ fn object_has_prototype_flag(obj_ptr: usize, flag: u64) -> bool { /// ordinary receiver answers `false` from one absent meta record plus one /// header bit. pub(crate) fn prototype_chain_ends_in_explicit_null(obj_ptr: usize) -> bool { + chain_ends_in_explicit_null_before(obj_ptr, 0) +} + +/// `x instanceof Object` for a shaped receiver: OrdinaryHasInstance finds +/// `Object.prototype` on the chain unless the chain ends in an explicit null +/// first (`Object.create(null)`, `__proto__: null`, a hop re-prototyped to +/// null). +pub(crate) fn prototype_chain_ends_in_null_before_object_prototype(obj_ptr: usize) -> bool { + let object_prototype = default_object_prototype_bits() + .map(|bits| crate::value::JSValue::from_bits(bits)) + .filter(|value| value.is_pointer()) + .map_or(0, |value| value.as_pointer::() as usize); + chain_ends_in_explicit_null_before(obj_ptr, object_prototype) +} + +/// [`prototype_chain_ends_in_explicit_null`], answering `false` as soon as +/// the walk reaches `stop` (0 = never). +fn chain_ends_in_explicit_null_before(obj_ptr: usize, stop: usize) -> bool { let mut current = obj_ptr; // The same bound the generic chain walk uses. A cycle cannot be built // through `setPrototypeOf` (it refuses one), but a bound is cheaper than // trusting that from here. for _ in 0..32 { - if unsafe { cell_is_born_null_proto(current) } { - return true; + if stop != 0 && current == stop { + return false; } match object_static_prototype(current) { + // A cell born with no prototype ends the chain unless a later + // link recorded one (the born-null header bit is sticky). + None if unsafe { cell_is_born_null_proto(current) } => return true, // No per-instance record on this hop. The chain does not stop // here: it continues through the hop's CLASS, which is where a // `class K {}` instance keeps `K.prototype`. Following it is what @@ -1218,15 +1268,21 @@ mod tests { let class_default = crate::object::js_object_alloc(0, 0); object_link_class_default_prototype(class_default as usize, crate::value::TAG_NULL); + // The prototype is a shape fact; whatever record the receiver keeps + // carries neither divergence signal. let class_default_meta = unsafe { (*class_default).meta }; - assert!(!class_default_meta.is_null()); - assert_eq!( - unsafe { (*class_default_meta).flags } - & (crate::object::OBJECT_META_FLAG_PROTO_DIVERGED - | crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE), - 0, + assert!( + class_default_meta.is_null() + || unsafe { (*class_default_meta).flags } + & (crate::object::OBJECT_META_FLAG_PROTO_DIVERGED + | crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE) + == 0, "class-default links must publish neither divergence signal" ); + assert_eq!( + object_static_prototype(class_default as usize), + Some(crate::value::TAG_NULL) + ); assert!(!object_has_prototype_divergence(class_default as usize)); let evaluated = crate::object::js_object_alloc(0, 0); diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index ec9f4c229c..68c6516223 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -36,12 +36,18 @@ use std::cell::RefCell; #[path = "shapes_birth_width.rs"] mod shapes_birth_width; +#[path = "shapes_prototype.rs"] +mod shapes_prototype; #[path = "shapes_slot_list.rs"] mod shapes_slot_list; #[path = "shapes_store.rs"] mod shapes_store; #[path = "shapes_worker_seed.rs"] mod shapes_worker_seed; +pub(crate) use shapes_prototype::{ + identity_word_slot, note_full_trace_begin, proto_id_carries_word, prune_dead_shape_prototypes, + scan_shape_prototype_words_mut, shape_prototype_word, +}; #[path = "shapes_store_kind.rs"] pub(crate) mod store_kind; pub(crate) use shapes_birth_width::{keyless_birth_width, note_spill_width}; @@ -273,6 +279,16 @@ impl ShapeRecordRef { self.0.as_ptr() as *mut u64 } + /// The record's [[Prototype]] identity word as a GC edge + /// (`shapes_prototype`): its address when the identity names a heap + /// object, else `None`. Every shape of one prototype hands the collector + /// the same word to mark through and rewrite in place. + #[inline] + pub(crate) fn prototype_slot(self, dedupe: bool) -> Option<*mut u64> { + // SAFETY: a live slab record (type docs). + shapes_prototype::identity_edge_slot(unsafe { (*self.0.as_ptr()).proto_id }, dedupe) + } + /// The record's field-representation word (`field_rep`), deprecated /// lanes included. #[inline] @@ -3703,6 +3719,7 @@ pub(crate) unsafe fn transition_object_shape_semantics_keeping_constfn( pub(crate) unsafe fn transition_object_shape_prototype( obj: *mut crate::object::ObjectHeader, proto_id: u64, + proto_bits: u64, ) -> u32 { if obj.is_null() || !shape_word_is_writable(obj) { return 0; @@ -3711,6 +3728,10 @@ pub(crate) unsafe fn transition_object_shape_prototype( synchronize_object_shape_descriptor(obj); object_shape_descriptor(obj).expect("shape synchronization must publish a descriptor") }); + // The identity's word names the prototype before any shape names the + // identity (`shapes_prototype`). The word is a root of every rewrite, so + // a mint below that collects repairs it with everything else. + shapes_prototype::write_identity_word(proto_id, proto_bits); if current.proto_id == proto_id { return object_shape_stamp(obj); } @@ -3745,7 +3766,7 @@ pub(crate) unsafe fn restamp_object_proto_id(obj: *mut crate::object::ObjectHead if obj.is_null() || !shape_word_is_writable(obj) || object_shape_stamp(obj) == 0 { return 0; } - transition_object_shape_prototype(obj, object_proto_id(obj)); + transition_object_shape_prototype(obj, object_proto_id(obj), object_prototype_word(obj)); // A `class_id` rewrite is also an F-A input (charter step 3, R4): a // prototype transition re-derives it, but an unchanged prototype // identity mints nothing, so re-derive explicitly. @@ -3884,6 +3905,95 @@ pub(crate) unsafe fn stable_linked_proto_id(class_id: u32, bits: u64) -> Option< /// # Safety /// `obj` is a live `ObjectHeader`. pub(crate) unsafe fn object_proto_id(obj: *const crate::object::ObjectHeader) -> u64 { + object_proto_id_for(obj, object_prototype_word(obj)) +} + +/// Does `id` name a present, keyless, generation-0, hole-free shape at +/// prototype identity `proto_id` with `slots` live inline slots — the facts +/// of a construction's birth shape (#10507's birth record)? One directory +/// read; no descriptor copy. +#[inline] +pub(crate) fn shape_is_keyless_birth(id: u32, proto_id: u64, slots: u32) -> bool { + let Some(record) = ShapeSlab::agent_record_present(id) else { + return false; + }; + // SAFETY: a present record of this agent, read immediately. + unsafe { + (*record).proto_id == proto_id + && (*record).logical_key_count == 0 + && (*record).semantic_generation == 0 + && (*record).hole_count == 0 + && (*record).live_inline_slot_count == slots + } +} + +/// A synthetic class id: a plain function constructor's (or +/// `Object.create`'s historical) class, the only receivers the prototype +/// funnel links without a meta record. +#[inline] +pub(crate) fn is_synthetic_class_id(class_id: u32) -> bool { + (SYNTHETIC_CLASS_ID_BASE + ..crate::object::class_registry::prototype_objects::SYNTHETIC_CLASS_ID_END) + .contains(&class_id) +} + +/// The first synthetic class id (`class_registry::prototype_objects`), for +/// the receivers [`is_synthetic_class_id`] admits. +pub(crate) const SYNTHETIC_CLASS_ID_BASE: u32 = + crate::object::class_registry::prototype_objects::SYNTHETIC_CLASS_ID_BASE; + +/// `obj`'s recorded [[Prototype]] bits, 0 when nothing is recorded (the +/// prototype is the default or the class's). A receiver that has a meta +/// record has it there (the prototype funnel writes both, and a +/// `PROTO_ID_PER_OBJECT` receiver's shape answers nothing); a meta-less +/// function-constructor instance reads it from its shape's identity word +/// (`shapes_prototype`). Allocation-free. +/// +/// # Safety +/// `obj` is a live `ObjectHeader`. +/// +/// A null [[Prototype]] is the identity `PROTO_ID_NULL` and reads back as +/// `TAG_NULL` — except on a cell BORN null (`Object.create(null)`, +/// `OBJ_FLAG_NULL_PROTO`), which answers 0 as it always has: every reader +/// tests that header bit for the born-null case, and a recorded null would +/// send it down the re-prototyped-receiver paths instead. +#[inline] +pub(crate) unsafe fn object_prototype_word(obj: *const crate::object::ObjectHeader) -> u64 { + let meta = (*obj).meta; + if !meta.is_null() && (*meta).prototype != 0 { + return (*meta).prototype; + } + // Only a function constructor's instance (a synthetic class) is linked + // without writing a meta record's word + // (`prototype_chain::object_set_static_prototype_impl`); it may have + // gained a record for something else since. Any other receiver without a + // recorded word recorded nothing, which one compare says. + if !is_synthetic_class_id((*obj).class_id) { + return 0; + } + // The agent directory read: never null, an absent id reads the empty + // record (identity 0, the default). + let proto_id = (*ShapeSlab::agent_record(object_shape_stamp(obj))).proto_id; + if proto_id != PROTO_ID_NULL { + return shapes_prototype::identity_prototype_word(proto_id); + } + match crate::value::addr_class::try_read_gc_header(obj as usize) { + Some(header) if header._reserved & crate::gc::OBJ_FLAG_NULL_PROTO != 0 => 0, + _ => crate::value::TAG_NULL, + } +} + +/// The prototype identity `obj` has when its recorded [[Prototype]] is +/// `recorded` (0 = none recorded): [`object_proto_id`] for a prototype being +/// linked, before any shape names it. +/// +/// # Safety +/// `obj` is a live `ObjectHeader`; `recorded` is 0, `TAG_NULL` or a value's +/// bits. +pub(crate) unsafe fn object_proto_id_for( + obj: *const crate::object::ObjectHeader, + recorded: u64, +) -> u64 { // A namespace's vtable/override registry can answer before its physical // own slots. Project that read classification into the shape, as for // process.env and arguments below; an own-slot region cannot admit it. @@ -3898,16 +4008,35 @@ pub(crate) unsafe fn object_proto_id(obj: *const crate::object::ObjectHeader) -> { return PROTO_ID_PER_OBJECT; } - if let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) { - if header._reserved & crate::gc::OBJ_FLAG_NULL_PROTO != 0 { - return PROTO_ID_NULL; + // A recorded prototype outranks the born-null header bit, which is + // sticky: `Object.setPrototypeOf(Object.create(null), p)` keeps the bit + // and its shape must still name `p`. + if recorded == 0 { + if let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) { + if header._reserved & crate::gc::OBJ_FLAG_NULL_PROTO != 0 { + return PROTO_ID_NULL; + } } } let class_id = (*obj).class_id; let class = vtable_class(class_id); - if !meta.is_null() && (*meta).prototype != 0 { - return stable_linked_proto_id(class_id, (*meta).prototype) - .unwrap_or_else(fresh_unique_proto_id); + if recorded != 0 { + if let Some(id) = stable_linked_proto_id(class_id, recorded) { + return id; + } + // A prototype with no stable identity gets one per link, carried by + // lineage: the receiver's own shape already has one for these bits + // when they are the ones its word holds. + let stamp = object_shape_stamp(obj); + let current = shape_proto_id(stamp).unwrap_or(PROTO_ID_DEFAULT); + if class == 0 + && current & PROTO_ID_UNIQUE == PROTO_ID_UNIQUE + && proto_id_carries_word(current) + && shape_prototype_word(stamp) == recorded + { + return current; + } + return fresh_unique_proto_id(); } if class != 0 { return PROTO_ID_CLASS | u64::from(class); diff --git a/crates/perry-runtime/src/object/shapes_prototype.rs b/crates/perry-runtime/src/object/shapes_prototype.rs new file mode 100644 index 0000000000..9744aac717 --- /dev/null +++ b/crates/perry-runtime/src/object/shapes_prototype.rs @@ -0,0 +1,356 @@ +//! The [[Prototype]] as a fact of the shape. +//! +//! A ShapeId's `proto_id` already names its receivers' prototype: two objects +//! share a ShapeId only if they share a prototype (`shapes::object_proto_id`). +//! This module adds the way back from that identity to the object, so a +//! receiver needs no per-instance record of its prototype: its ShapeId names +//! the identity, and the identity's WORD is the receiver's [[Prototype]]. +//! +//! * Which identities have a word: [`proto_id_carries_word`]. These are a +//! recorded prototype's serial, `MIXED` (class | serial, which uses the +//! serial's word) and `UNIQUE` (a prototype with no serial, one identity per +//! link). Null, the default, class-implied and per-object identities answer +//! by themselves (`shapes::object_prototype_word`). +//! * One word per IDENTITY, not per shape record: every shape of one +//! prototype names the same word, and the record stays one cache line. The +//! words live in stable pages (an address never moves). The pages are +//! published to a thread-local directory, so a read is the record's +//! `proto_id` plus two loads. The prototype funnel +//! (`shapes::transition_object_shape_prototype`) writes the word before any +//! shape names the identity. +//! * Who reads it: a function constructor's instance, the receiver the funnel +//! links without a meta record. A receiver that has a meta record anyway +//! (link flags, a per-object identity) keeps the same bits in it, which is +//! the cheaper read. +//! * GC: the word is a traced edge of its CARRIERS, like the record's keys +//! word (`gc_shape_prototype_edge_slot`), so a prototype lives exactly while +//! something reaches it. +//! - In a full trace only the first carrier of each identity emits the edge +//! ([`identity_edge_slot`]): that visit marks the prototype. +//! - A minor never traces an old carrier, so [`scan_shape_prototype_words_mut`] +//! roots every word that names a young object (the young log). +//! - Rewrite passes repair every word through forwarding. +//! - [`prune_dead_shape_prototypes`] clears a word whose prototype died. +//! * Workers: the seed copies no record whose identity has a word, so a +//! pointer never crosses agents. + +use std::cell::Cell; + +use super::{PROTO_ID_CLASS, PROTO_ID_DEFAULT, PROTO_ID_MIXED, PROTO_ID_NULL, PROTO_ID_PER_OBJECT}; + +/// The tag bits of a prototype identity (`shapes::PROTO_ID_*`). +const PROTO_ID_TAG_MASK: u64 = 3 << 62; +/// The serial bits of a `MIXED` identity (`shapes::PROTO_ID_MIXED_SERIAL_BITS`). +const MIXED_SERIAL_MASK: u64 = (1 << 30) - 1; +const PAGE_SHIFT: usize = 10; +const PAGE_LEN: usize = 1 << PAGE_SHIFT; +const PAGE_MASK: usize = PAGE_LEN - 1; + +/// One page of identity words, with the full-trace epoch each word's edge +/// was last emitted in (`identity_edge_slot`). +struct PageData { + words: [u64; PAGE_LEN], + emitted: [u32; PAGE_LEN], +} + +type Page = Box; + +/// Does identity `proto_id` name its prototype through a word? False for an +/// identity that answers by itself: the realm's default, a compiled class's, +/// a per-object one, and null. +#[inline] +pub(crate) fn proto_id_carries_word(proto_id: u64) -> bool { + proto_id != PROTO_ID_DEFAULT + && proto_id != PROTO_ID_NULL + && proto_id != PROTO_ID_PER_OBJECT + && proto_id & PROTO_ID_TAG_MASK != PROTO_ID_CLASS +} + +/// The (band, index) of identity `proto_id`'s word: band 0 is indexed by +/// prototype serial (a plain serial identity and a `MIXED` one share it — one +/// object), band 1 by `UNIQUE` number. +#[inline] +fn word_key(proto_id: u64) -> Option<(usize, usize)> { + if !proto_id_carries_word(proto_id) { + return None; + } + Some(match proto_id & PROTO_ID_TAG_MASK { + 0 => (0, proto_id as usize), + PROTO_ID_MIXED => (0, (proto_id & MIXED_SERIAL_MASK) as usize), + _ => (1, (proto_id & !PROTO_ID_TAG_MASK) as usize), + }) +} + +/// The agent's published page directory, one per band: read with no +/// `state()` fetch, as `shapes_store::AGENT_SHAPE_DIR` is. +struct WordDir { + pages: Cell<*mut Option>, + len: Cell, +} + +impl WordDir { + const fn empty() -> Self { + WordDir { + pages: Cell::new(std::ptr::null_mut()), + len: Cell::new(0), + } + } +} + +#[thread_local] +static AGENT_WORD_DIR: [WordDir; 2] = [WordDir::empty(), WordDir::empty()]; + +/// The current full trace, counted from 1 (`note_full_trace_begin`). A word +/// whose edge was emitted in this trace is not emitted again: the first +/// carrier marked the prototype, and every later visit would only re-test it. +#[thread_local] +static FULL_TRACE_EPOCH: Cell = Cell::new(0); + +/// A full trace begins (`gc::full_trace::begin_full_trace`). +pub(crate) fn note_full_trace_begin() { + FULL_TRACE_EPOCH.set(FULL_TRACE_EPOCH.get().wrapping_add(1).max(1)); +} + +/// Identity -> [[Prototype]] words, per agent (owned by the agent's shape +/// slab). Pages are allocated on first use and never move or shrink. +#[derive(Default)] +pub(crate) struct ProtoWords { + bands: [Vec>; 2], + /// Word addresses that named a nursery object when written or last + /// visited: the minor's complete candidate set. + young: Vec<*mut u64>, + agent: bool, +} + +#[inline] +fn bits_in_nursery(bits: u64) -> bool { + let value = crate::value::JSValue::from_bits(bits); + value.is_pointer() && crate::arena::pointer_in_nursery(value.as_pointer::() as usize) +} + +impl ProtoWords { + /// This is the agent slab's: publish its directory. + pub(super) fn make_agent(&mut self) { + self.agent = true; + self.publish(); + } + + fn publish(&mut self) { + if !self.agent { + return; + } + for (band, dir) in AGENT_WORD_DIR.iter().enumerate() { + dir.pages.set(self.bands[band].as_mut_ptr()); + dir.len.set(self.bands[band].len()); + } + } + + pub(super) fn unpublish(&self) { + if !self.agent { + return; + } + for dir in &AGENT_WORD_DIR { + dir.pages.set(std::ptr::null_mut()); + dir.len.set(0); + } + } + + /// Drop every word (a test resetting the shape table). + #[cfg(test)] + pub(super) fn reset(&mut self) { + let agent = self.agent; + *self = ProtoWords::default(); + self.agent = agent; + self.publish(); + } + + fn slot_ensure(&mut self, band: usize, index: usize) -> *mut u64 { + let page = index >> PAGE_SHIFT; + let pages = &mut self.bands[band]; + if page >= pages.len() { + pages.resize_with(page + 1, || None); + self.publish(); + } + let page = self.bands[band][page].get_or_insert_with(|| { + Box::new(PageData { + words: [0; PAGE_LEN], + emitted: [0; PAGE_LEN], + }) + }); + &mut page.words[index & PAGE_MASK] + } + + /// Visit every word that holds a heap reference. + fn for_each_word(&mut self, mut f: impl FnMut(*mut u64)) { + for pages in &mut self.bands { + for page in pages.iter_mut().flatten() { + for word in page.words.iter_mut() { + if crate::value::JSValue::from_bits(*word).is_pointer() { + f(word); + } + } + } + } + } +} + +/// The address of identity `proto_id`'s word in this agent, if it was ever +/// written. Two loads; no `state()` fetch. +#[inline] +pub(crate) fn identity_word_slot(proto_id: u64) -> Option<*mut u64> { + let (band, index) = word_key(proto_id)?; + let dir = &AGENT_WORD_DIR[band]; + let page = index >> PAGE_SHIFT; + if page >= dir.len.get() { + return None; + } + // SAFETY: the published directory is the agent's own page vector, current + // as of its last growth; a present page is a stable boxed array. + unsafe { + (*dir.pages.get().add(page)) + .as_ref() + .map(|page| &page.words[index & PAGE_MASK] as *const u64 as *mut u64) + } +} + +/// The GC edge of identity `proto_id`'s word for a carrier being traced: the +/// word's address when it holds a heap reference. In a FULL trace (`dedupe`) +/// only the first carrier emits it — that visit marks the prototype, and the +/// trace's rewrite needs no carrier at all (`scan_shape_prototype_words_mut` +/// repairs every word) — so the thousands of instances of one constructor +/// do not each re-test one marked object. A minor emits it for every carrier. +#[inline] +pub(crate) fn identity_edge_slot(proto_id: u64, dedupe: bool) -> Option<*mut u64> { + let (band, index) = word_key(proto_id)?; + let dir = &AGENT_WORD_DIR[band]; + let page = index >> PAGE_SHIFT; + if page >= dir.len.get() { + return None; + } + // SAFETY: the published directory is the agent's own page vector; a + // present page is a stable boxed page, and the collector owns the agent. + unsafe { + let page: *mut PageData = &mut **(*dir.pages.get().add(page)).as_mut()?; + let index = index & PAGE_MASK; + let word = &mut (*page).words[index]; + if !crate::value::JSValue::from_bits(*word).is_pointer() { + return None; + } + if dedupe { + let epoch = FULL_TRACE_EPOCH.get(); + if (*page).emitted[index] == epoch { + return None; + } + (*page).emitted[index] = epoch; + } + Some(word) + } +} + +/// The [[Prototype]] bits identity `proto_id` names, or 0. +#[inline] +pub(crate) fn identity_prototype_word(proto_id: u64) -> u64 { + // SAFETY: a published word slot of this agent. + identity_word_slot(proto_id).map_or(0, |slot| unsafe { *slot }) +} + +/// The prototype word of shape `id`: the receiver's [[Prototype]] bits when +/// the shape's identity names an object, else 0. +#[inline] +pub(crate) fn shape_prototype_word(id: u32) -> u64 { + // SAFETY: `agent_record` never returns null; an absent id reads the + // shared empty record, whose identity is the default. + identity_prototype_word(unsafe { (*super::shapes_store::ShapeSlab::agent_record(id)).proto_id }) +} + +/// Make identity `proto_id` name `bits`, before any shape names it (the +/// prototype funnel). An identity names one object for its whole life, so +/// the write only ever fills an empty word or repeats the same bits — or +/// replaces bits whose object died (the prune clears those). +pub(super) fn write_identity_word(proto_id: u64, bits: u64) { + let Some((band, index)) = word_key(proto_id) else { + return; + }; + let table = &crate::state::state().shapes; + // SAFETY: one agent, one thread; no slab reference is held across this. + let words = unsafe { &mut table.slab_mut().protos }; + let slot = words.slot_ensure(band, index); + // SAFETY: a stable word of this agent's pages. + unsafe { + // GC_STORE_AUDIT(ROOT): identity word, scanned by + // scan_shape_prototype_words_mut and traced through its carriers. + *slot = bits; + } + if bits_in_nursery(bits) { + words.young.push(slot); + } +} + +/// The identity words outside the per-carrier edge. +/// +/// Marking: a receiver the collector traces emits its identity's word itself +/// (`gc_shape_prototype_edge_slot`), so a full mark roots nothing here and a +/// prototype whose last carrier died is collectable. A minor never traces an +/// OLD carrier, so it roots every word naming a young object — the young log +/// is the complete set. +/// +/// Rewriting: every word is repaired through forwarding. +pub(crate) fn scan_shape_prototype_words_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + let table = &crate::state::state().shapes; + // SAFETY: the collector runs on the owning agent with the mutator + // stopped; no other slab reference is live. + let words = unsafe { &mut table.slab_mut().protos }; + if visitor.young_scope() { + let mut log = std::mem::take(&mut words.young); + log.sort_unstable(); + log.dedup(); + let mut young = Vec::with_capacity(log.len()); + for slot in log { + // SAFETY: a stable word of this agent's pages. + unsafe { + if !bits_in_nursery(*slot) { + continue; + } + visitor.visit_nanbox_u64_slot(&mut *slot); + if bits_in_nursery(*slot) { + young.push(slot); + } + } + } + words.young = young; + } else if visitor.is_metadata_rewrite_phase() { + let mut young = Vec::new(); + words.for_each_word(|slot| { + // SAFETY: a stable word of this agent's pages. + unsafe { + visitor.visit_nanbox_u64_slot(&mut *slot); + if bits_in_nursery(*slot) { + young.push(slot); + } + } + }); + words.young = young; + } +} + +/// Post-trace prune: clear a word whose prototype died. No live receiver can +/// carry its identity (a carrier traces the word), so nothing reads it again. +pub(crate) fn prune_dead_shape_prototypes(is_dead_owner: &dyn Fn(usize) -> bool) { + let table = &crate::state::state().shapes; + // SAFETY: one agent, one thread; no slab reference is held across this. + let words = unsafe { &mut table.slab_mut().protos }; + words.for_each_word(|slot| { + // SAFETY: a stable word of this agent's pages. + unsafe { + let value = crate::value::JSValue::from_bits(*slot); + if is_dead_owner(value.as_pointer::() as usize) { + *slot = 0; + } + } + }); + words.young.retain(|&slot| unsafe { *slot } != 0); +} + +#[cfg(test)] +#[path = "shapes_prototype_tests.rs"] +mod tests; diff --git a/crates/perry-runtime/src/object/shapes_prototype_tests.rs b/crates/perry-runtime/src/object/shapes_prototype_tests.rs new file mode 100644 index 0000000000..13dde655a3 --- /dev/null +++ b/crates/perry-runtime/src/object/shapes_prototype_tests.rs @@ -0,0 +1,86 @@ +//! The [[Prototype]] as a shape fact (`shapes_prototype`). + +use super::*; +use crate::object::prototype_chain::{ + object_link_class_default_prototype, object_set_user_prototype, object_static_prototype, +}; +use crate::object::{js_object_alloc, ObjectHeader}; + +fn bits(obj: *mut ObjectHeader) -> u64 { + crate::value::js_nanbox_pointer(obj as i64).to_bits() +} + +#[test] +fn a_class_default_link_records_the_prototype_in_the_shape_only() { + let _no_move = crate::gc::GcSuppressScope::new(); + let proto = js_object_alloc(0, 0); + // A function constructor's instance: a synthetic class id. + let obj = js_object_alloc(crate::object::shapes::SYNTHETIC_CLASS_ID_BASE + 0x51, 0); + object_link_class_default_prototype(obj as usize, bits(proto)); + let stamp = unsafe { super::super::object_shape_stamp(obj) }; + assert!( + unsafe { (*obj).meta }.is_null(), + "a class-default link allocates no per-instance record" + ); + assert_eq!(shape_prototype_word(stamp), bits(proto)); + assert_eq!(object_static_prototype(obj as usize), Some(bits(proto))); + assert!(proto_id_carries_word( + super::super::shape_proto_id(stamp).unwrap() + )); +} + +/// The sabotage target: the prototype identity is part of the shape key, so +/// two receivers with the same keys and different prototypes never share a +/// ShapeId — and so never share a prototype word. +#[test] +fn receivers_with_different_prototypes_never_share_a_shape() { + let _no_move = crate::gc::GcSuppressScope::new(); + let p1 = js_object_alloc(0, 0); + let p2 = js_object_alloc(0, 0); + let a = js_object_alloc(0, 0); + let b = js_object_alloc(0, 0); + let c = js_object_alloc(0, 0); + object_link_class_default_prototype(a as usize, bits(p1)); + object_link_class_default_prototype(b as usize, bits(p2)); + object_link_class_default_prototype(c as usize, bits(p1)); + let (sa, sb, sc) = unsafe { + ( + super::super::object_shape_stamp(a), + super::super::object_shape_stamp(b), + super::super::object_shape_stamp(c), + ) + }; + assert_ne!(sa, sb, "two prototypes, one ShapeId"); + assert_eq!(sa, sc, "one prototype, one predecessor: one ShapeId"); + assert_eq!(object_static_prototype(a as usize), Some(bits(p1))); + assert_eq!(object_static_prototype(b as usize), Some(bits(p2))); + assert_eq!(object_static_prototype(c as usize), Some(bits(p1))); + // A later prototype change moves the receiver, never the shape's word. + object_set_user_prototype(c as usize, bits(p2)); + assert_eq!(object_static_prototype(c as usize), Some(bits(p2))); + assert_eq!(object_static_prototype(a as usize), Some(bits(p1))); + assert_eq!(shape_prototype_word(sa), bits(p1)); +} + +#[test] +fn a_null_prototype_is_a_shape_fact() { + let _no_move = crate::gc::GcSuppressScope::new(); + let obj = js_object_alloc(0, 0); + object_set_user_prototype(obj as usize, crate::value::TAG_NULL); + let stamp = unsafe { super::super::object_shape_stamp(obj) }; + assert_eq!( + super::super::shape_proto_id(stamp), + Some(super::super::PROTO_ID_NULL) + ); + assert_eq!( + unsafe { crate::object::shapes::object_prototype_word(obj) }, + crate::value::TAG_NULL + ); + assert_eq!( + object_static_prototype(obj as usize), + Some(crate::value::TAG_NULL) + ); + // The link's user-override FLAG needs a meta record, and a receiver with + // one keeps the same word there (the cheaper read). + assert_eq!(unsafe { (*(*obj).meta).prototype }, crate::value::TAG_NULL); +} diff --git a/crates/perry-runtime/src/object/shapes_slot_list.rs b/crates/perry-runtime/src/object/shapes_slot_list.rs index 1acea176bf..4ad2e18a03 100644 --- a/crates/perry-runtime/src/object/shapes_slot_list.rs +++ b/crates/perry-runtime/src/object/shapes_slot_list.rs @@ -1291,7 +1291,13 @@ pub(crate) fn shape_id_owns_keys_slot(shape_id: u32, slot: *mut u64) -> bool { .shapes .slab() .record_ptr(shape_id) - .is_some_and(|record| record as *mut u64 == slot) + .is_some_and(|record| { + // The keys word is the record's first field; the identity's + // [[Prototype]] word (`shapes_prototype`) is the same kind of + // shared edge. + record as *mut u64 == slot + || super::identity_word_slot(unsafe { (*record).proto_id }) == Some(slot) + }) } #[cfg(test)] diff --git a/crates/perry-runtime/src/object/shapes_store.rs b/crates/perry-runtime/src/object/shapes_store.rs index 8377e595e7..a001960693 100644 --- a/crates/perry-runtime/src/object/shapes_store.rs +++ b/crates/perry-runtime/src/object/shapes_store.rs @@ -1027,6 +1027,8 @@ pub(crate) struct ShapeSlab { /// publishes its directories into [`AGENT_SHAPE_DIR`]. A slab a test /// builds on its own does not. agent: bool, + /// The agent's identity -> [[Prototype]] words (`shapes_prototype`). + pub(super) protos: super::shapes_prototype::ProtoWords, } impl Drop for ShapeSlab { @@ -1036,6 +1038,7 @@ impl Drop for ShapeSlab { band.pages.set(std::ptr::null()); band.len.set(0); } + self.protos.unpublish(); } // Retired records release on removal; live records release at agent // teardown. Rekeys transfer one pointer, never duplicate ownership. @@ -1055,6 +1058,7 @@ impl ShapeSlab { exotic_pages: Vec::new(), len: 0, agent: false, + protos: Default::default(), } } @@ -1064,6 +1068,7 @@ impl ShapeSlab { let mut slab = Self::new(); slab.agent = true; slab.publish_dir(); + slab.protos.make_agent(); slab } @@ -1399,6 +1404,7 @@ impl ShapeSlab { } self.publish_dir(); self.len = 0; + self.protos.reset(); } /// Bytes held: the page directory, every allocated page and every diff --git a/crates/perry-runtime/src/object/shapes_store_tests.rs b/crates/perry-runtime/src/object/shapes_store_tests.rs index fc005a9f07..ad77bf8e28 100644 --- a/crates/perry-runtime/src/object/shapes_store_tests.rs +++ b/crates/perry-runtime/src/object/shapes_store_tests.rs @@ -253,6 +253,8 @@ fn lifted_descriptor_mirrors_the_record_and_names_its_address() { /// moves to offset 48 behind it. /// 56 -> 64 is the record-owned ConstFn extension pointer; no side table /// or pointer to a heap closure participates in shape identity. +/// The [[Prototype]] itself is NOT in the record: it is one word per prototype +/// identity (`shapes_prototype`), so the record stays one cache line. #[test] fn the_record_geometry_is_free_and_facts_key_is_o1() { assert_eq!(std::mem::size_of::(), 64, "record grew"); diff --git a/crates/perry-runtime/src/object/shapes_worker_seed.rs b/crates/perry-runtime/src/object/shapes_worker_seed.rs index 7ec6210fd9..8ca57429c0 100644 --- a/crates/perry-runtime/src/object/shapes_worker_seed.rs +++ b/crates/perry-runtime/src/object/shapes_worker_seed.rs @@ -43,6 +43,10 @@ pub(crate) fn worker_shape_seed() -> WorkerShapeSeed { && r.object_kind() == ShapeObjectKind::Ordinary && r.semantic_generation == 0 && r.summary() == 0 + // A record whose identity names a prototype object holds a + // pointer into THIS agent's heap (`shapes_prototype`); a worker + // mints its own. + && !super::proto_id_carries_word(r.proto_id) { // Copy key BYTES while the source agent owns the record. A worker // builds its own canonical keys, so no moving source key pointer diff --git a/crates/perry-runtime/src/proxy/put_value/setter_site.rs b/crates/perry-runtime/src/proxy/put_value/setter_site.rs index 90e5a34b0f..edc8a954c9 100644 --- a/crates/perry-runtime/src/proxy/put_value/setter_site.rs +++ b/crates/perry-runtime/src/proxy/put_value/setter_site.rs @@ -117,11 +117,7 @@ unsafe fn class_link(recv: *const crate::ObjectHeader) -> Option<*const crate::O return None; } let holder = if (PROTO_ID_MIXED..PROTO_ID_UNIQUE).contains(&pid) { - let meta = (*recv).meta; - if meta.is_null() { - return None; - } - let p = crate::JSValue::from_bits((*meta).prototype); + let p = crate::JSValue::from_bits(crate::object::shapes::object_prototype_word(recv)); if !p.is_pointer() { return None; } diff --git a/crates/perry-runtime/src/timer/handle_object.rs b/crates/perry-runtime/src/timer/handle_object.rs index 6266ddd532..dbba29c476 100644 --- a/crates/perry-runtime/src/timer/handle_object.rs +++ b/crates/perry-runtime/src/timer/handle_object.rs @@ -240,8 +240,9 @@ pub(crate) unsafe fn try_timer_method_fast_dispatch(object: f64, name: &[u8]) -> if proto.is_null() { return None; } - // `timer_handle_parts` proved `meta` non-null. - if (*(*obj).meta).prototype != crate::value::js_nanbox_pointer(proto as i64).to_bits() { + if crate::object::shapes::object_prototype_word(obj) + != crate::value::js_nanbox_pointer(proto as i64).to_bits() + { return None; } let name_str = std::str::from_utf8_unchecked(name); diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index 3c2a24b540..63947a097c 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -1606,10 +1606,12 @@ pub unsafe extern "C" fn js_object_own_method_cache_miss( { return 0; } + if crate::object::shapes::object_prototype_word(object) != 0 { + return 0; + } let meta = (*object).meta; if !meta.is_null() - && ((*meta).prototype != 0 - || (*meta).attr_key_bits != 0 + && ((*meta).attr_key_bits != 0 || (*meta).accessor_key_bits != 0 || (*meta).flags != 0 || (*meta).private_evaluation_brand != 0) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index eba09251e0..c950ae095b 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -343,7 +343,11 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", +<<<<<<< 2572d26ad0d5a37d6987cd3bc5db9aa98191c57b "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 (charter step 5, P0): the field-representation census adds `mod census_field_repr;` to `gc/mod.rs` and, in `census.rs`, one Rust-owned accumulator fed from `visit_object` inside `take_census`, which runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED; it reads slots and the per-object layout maps (try_borrow), allocates nothing on the JS heap and alters no mark/sweep control flow. The window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 (class constructors as function objects, #11414): `gc/mod.rs` gains one `reg_scanner!` registration (`object::class_value::scan_class_value_roots_mut`, the per-agent class function-object table, visited and rewritten) \u2014 a root-scanner registration that alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 after #11659 added `verify::verify_array_hole_tails_at_collection()` to `gc/mod.rs` and `gc/policy.rs`. Every call sits at a collection or budgeted-cycle ENTRY, beside `roots::ensure_stack_maps_built()`, i.e. before mark begins and so before `census_pass1_if_armed` opens the window. The call is a read-only walk of array headers (debug builds, or release with PERRY_GC_VERIFY_ARRAY_HOLES): it neither allocates GC memory, relocates, runs JS, nor reads or writes PASS1_MARKED, and it panics rather than continuing on a violation. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Pin re-audited 2026-09-29 after PR #11646 added one call inside that window, in `cycle.rs` beside `census_take_if_armed_at_full_sweep_start`: `object::shapes::store_kind::audit_heap_at_full_sweep_start()`. It compiles to nothing unless the `shape-fact-audit` feature is on; when on it walks the arena read-only (`gc::for_each_live_object_at_sweep_start`, in `gc/verify.rs`) and reads each marked object header and its shape record. It allocates no GC object, moves nothing, runs no JS callback and holds no address past the walk, so PASS1_MARKED stays valid across it; a disagreement panics rather than continuing. Re-audited 2026-09-29 after the pinned-roots fix touched `gc/cycle.rs` and `gc/mod.rs`: the block-persistence live-block predicate now reads GC_FLAG_MARKED alone (pinned objects are marked as roots, so the set of live blocks is unchanged), and `gc/mod.rs` adds one `reg_scanner!` registration. Neither relocates, runs JS, or moves the mark-complete to sweep-entry window. Re-audited again 2026-09-29 (same fix, copying-minor follow-up): `BlockPersistCycleState`'s force-mark in `gc/cycle.rs` no longer skips an unmarked pinned header, so it marks and pushes it like any other object of a live block. That is marking inside the budgeted cycle's persistence step: nothing relocates, no JS runs, and the budgeted path still skips both census boundaries. The window is unchanged. Re-audited 2026-09-29 for this-as-a-parameter stage 3: `gc/mod.rs` registers `scan_dispatch_binding_roots_mut` in place of `scan_implicit_this_roots_mut` (the implicit-`this` cell is deleted; the same scanner body keeps `new.target`, the static-`this` override and the static private-owner stack) and rewords its comment; a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for the JsFunctionInfo lane: `gc/census.rs`'s `side_tables()` loses its `closure_registry_census()` row (the closure-body registry is deleted); a census report row, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 for the read-site holder entry: `gc/mod.rs` adds one `reg_scanner!` registration, `read_holder::scan_read_holder_roots_mut`, which visits the holder and hop words of registered read-site caches during ROOT SCAN, before mark propagation completes. It rewrites root slots only through the visitor (as every registered scanner does), runs no JS, and nothing it does executes between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. The window is unchanged. Re-audited 2026-09-30 for Step 5 P4: census.rs only removes the typed-layout count, and gc/mod.rs removes shape-install registration/re-export and an init-time typed-intact verifier gate. The snapshot remains stored after mark propagation and taken at sweep entry; none of these edits relocates objects or invokes callbacks in that interval. Re-audited 2026-09-29 (#11549 trace cost): `gc/mod.rs` gains one `mod copying_object_scan;` declaration, the copying minor's plain-object slot walk. It is reached only from the copying drain, never from a full cycle, and changes no mark or sweep control flow. The window is unchanged. Re-audited 2026-09-30 after rebasing P4 over #11549/#11676: gc/mod.rs retains the copying_object_scan declaration and P4 removes typed-layout registration; both are module wiring outside the synchronous full mark-complete to sweep-entry window. The census boundaries and the window owner in cycle.rs are unchanged. Re-audited 2026-09-30 for the scope-context change: gc/mod.rs only removes the box root-scanner registration and exit-time box statistics. The former ran during root scan, before mark completion, and the latter runs at process exit. Neither changes the synchronous mark-complete to sweep-entry window. Re-audited 2026-09-28 for #11605 (link-time runtime feature installs): `gc/mod.rs` registers the interpreter root scanner through its always-present forwarder (`dyn_eval_hooks::scan_dyn_eval_roots_mut`, which calls the real scanner through a slot the `dyn-eval` install fills) instead of a feature-gated direct registration, and `js_gc_init` ends by running the program\u2019s feature installer, which only stores fn pointers into `Hook` slots at startup, before any user code or collection. Neither alters mark/sweep control flow or runs inside the mark-complete to sweep-entry window. Re-audited 2026-10-01 after main integration: the pinned census, cycle, policy and progress files are byte-identical to current main. The only gc/mod.rs delta replaces the interpreter scanner registration with its installed-slot forwarder and invokes the installer at js_gc_init startup, before user code and any collection. No change executes between mark completion and sweep entry. Re-audited 2026-09-28 for the release-runtime instrument strip: every changed line in `gc/census.rs`, `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs` renames a `feature = \"diagnostics\" | \"gc-instruments\" | \"hot-diag\"` gate to the build-script cfg `perry_diagnostics` / `perry_gc_instruments` / `perry_hot_diag`, which `perry-runtime/build.rs` sets exactly when the feature is on unless PERRY_RELEASE_STRIP_INSTRUMENTS=1. With the cfg set the compiled code is unchanged; with it unset (release packages) the census that fills this snapshot is not compiled, so the window never opens. No mark/sweep control flow changes. Re-audited 2026-10-01 while integrating the repaired #11605 parent: all five pinned files are byte-identical to that reviewed parent after normalizing only the diagnostics/instrument cfg names; its current census_field_repr module and startup installer hooks remain intact. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited for A2 inherited-read-cache deletion: gc/mod.rs removes only its old root-scanner registration and adjacent comments. The method-site and read-holder scanners still run during root scan before mark completion; no collection phase or callback was added inside the mark-complete to sweep-entry window. Re-audited 2026-09-30 for the setter-site root scanner: gc/mod.rs adds only its reg_scanner! registration in the root-scan setup. That scanner visits per-site key and holder slots before mark propagation completes; it runs no JS and adds no relocation or callback between census pass1 and sweep entry. The synchronous-full snapshot window and both boundaries remain unchanged. Re-audited 2026-10-01 against main a8f4f3dd76: census.rs, cycle.rs, policy.rs and progress.rs are byte-identical to reviewed #11629 head 85e0fe18ec. The only pinned-file delta in gc/mod.rs removes the deleted inherited-read-cache scanner, retains chain-store/method-site/read-holder scanners, and registers the setter-site scanner during gc_init. These registrations run during root scanning before mark completion. Both link-time feature installers and release instrument cfgs are preserved; neither census boundary nor the non-moving synchronous-full interval changes. Re-audited 2026-09-28 for #11605 (link-time runtime feature installs): `gc/mod.rs` registers the interpreter root scanner through its always-present forwarder (`dyn_eval_hooks::scan_dyn_eval_roots_mut`, which calls the real scanner through a slot the `dyn-eval` install fills) instead of a feature-gated direct registration, and `js_gc_init` ends by running the program\u2019s feature installer, which only stores fn pointers into `Hook` slots at startup, before any user code or collection. Neither alters mark/sweep control flow or runs inside the mark-complete to sweep-entry window. Re-audited 2026-10-01 after main integration: the pinned census, cycle, policy and progress files are byte-identical to current main. The only gc/mod.rs delta replaces the interpreter scanner registration with its installed-slot forwarder and invokes the installer at js_gc_init startup, before user code and any collection. No change executes between mark completion and sweep entry. Re-audited 2026-10-01 while integrating main 7b5912d4e7 into #11605: census.rs, cycle.rs, policy.rs and progress.rs are byte-identical to that main. The only gc/mod.rs differences register the existing interpreter root scanner through its installed-slot forwarder and run the selected feature installer at js_gc_init startup before user code. The current main method/read-holder/setter-site scanners are preserved. No work was added between mark completion and sweep entry; the synchronous non-moving snapshot window is unchanged. Re-audited 2026-10-02 for the SPECIAL ConstFn verifier: gc/mod.rs adds only a feature-gated forwarding-helper re-export. The helper follows existing validated forwarding and runs no JS, allocation or collection. No holder, scanner registration or mark/sweep control flow is added; both census boundaries and their synchronous window remain unchanged.", +======= + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes — in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 (charter step 5, P0): the field-representation census adds `mod census_field_repr;` to `gc/mod.rs` and, in `census.rs`, one Rust-owned accumulator fed from `visit_object` inside `take_census`, which runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED; it reads slots and the per-object layout maps (try_borrow), allocates nothing on the JS heap and alters no mark/sweep control flow. The window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 (class constructors as function objects, #11414): `gc/mod.rs` gains one `reg_scanner!` registration (`object::class_value::scan_class_value_roots_mut`, the per-agent class function-object table, visited and rewritten) — a root-scanner registration that alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 after #11659 added `verify::verify_array_hole_tails_at_collection()` to `gc/mod.rs` and `gc/policy.rs`. Every call sits at a collection or budgeted-cycle ENTRY, beside `roots::ensure_stack_maps_built()`, i.e. before mark begins and so before `census_pass1_if_armed` opens the window. The call is a read-only walk of array headers (debug builds, or release with PERRY_GC_VERIFY_ARRAY_HOLES): it neither allocates GC memory, relocates, runs JS, nor reads or writes PASS1_MARKED, and it panics rather than continuing on a violation. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Pin re-audited 2026-09-29 after PR #11646 added one call inside that window, in `cycle.rs` beside `census_take_if_armed_at_full_sweep_start`: `object::shapes::store_kind::audit_heap_at_full_sweep_start()`. It compiles to nothing unless the `shape-fact-audit` feature is on; when on it walks the arena read-only (`gc::for_each_live_object_at_sweep_start`, in `gc/verify.rs`) and reads each marked object header and its shape record. It allocates no GC object, moves nothing, runs no JS callback and holds no address past the walk, so PASS1_MARKED stays valid across it; a disagreement panics rather than continuing. Re-audited 2026-09-29 after the pinned-roots fix touched `gc/cycle.rs` and `gc/mod.rs`: the block-persistence live-block predicate now reads GC_FLAG_MARKED alone (pinned objects are marked as roots, so the set of live blocks is unchanged), and `gc/mod.rs` adds one `reg_scanner!` registration. Neither relocates, runs JS, or moves the mark-complete to sweep-entry window. Re-audited again 2026-09-29 (same fix, copying-minor follow-up): `BlockPersistCycleState`'s force-mark in `gc/cycle.rs` no longer skips an unmarked pinned header, so it marks and pushes it like any other object of a live block. That is marking inside the budgeted cycle's persistence step: nothing relocates, no JS runs, and the budgeted path still skips both census boundaries. The window is unchanged. Re-audited 2026-09-29 for this-as-a-parameter stage 3: `gc/mod.rs` registers `scan_dispatch_binding_roots_mut` in place of `scan_implicit_this_roots_mut` (the implicit-`this` cell is deleted; the same scanner body keeps `new.target`, the static-`this` override and the static private-owner stack) and rewords its comment; a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for the JsFunctionInfo lane: `gc/census.rs`'s `side_tables()` loses its `closure_registry_census()` row (the closure-body registry is deleted); a census report row, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 for the read-site holder entry: `gc/mod.rs` adds one `reg_scanner!` registration, `read_holder::scan_read_holder_roots_mut`, which visits the holder and hop words of registered read-site caches during ROOT SCAN, before mark propagation completes. It rewrites root slots only through the visitor (as every registered scanner does), runs no JS, and nothing it does executes between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. The window is unchanged. Re-audited 2026-09-30 for Step 5 P4: census.rs only removes the typed-layout count, and gc/mod.rs removes shape-install registration/re-export and an init-time typed-intact verifier gate. The snapshot remains stored after mark propagation and taken at sweep entry; none of these edits relocates objects or invokes callbacks in that interval. Re-audited 2026-09-29 (#11549 trace cost): `gc/mod.rs` gains one `mod copying_object_scan;` declaration, the copying minor's plain-object slot walk. It is reached only from the copying drain, never from a full cycle, and changes no mark or sweep control flow. The window is unchanged. Re-audited 2026-09-30 after rebasing P4 over #11549/#11676: gc/mod.rs retains the copying_object_scan declaration and P4 removes typed-layout registration; both are module wiring outside the synchronous full mark-complete to sweep-entry window. The census boundaries and the window owner in cycle.rs are unchanged. Re-audited 2026-09-30 for the scope-context change: gc/mod.rs only removes the box root-scanner registration and exit-time box statistics. The former ran during root scan, before mark completion, and the latter runs at process exit. Neither changes the synchronous mark-complete to sweep-entry window. Re-audited for A2 inherited-read-cache deletion: gc/mod.rs removes only its old root-scanner registration and adjacent comments. The method-site and read-holder scanners still run during root scan before mark completion; no collection phase or callback was added inside the mark-complete to sweep-entry window. Re-audited 2026-09-30 for the setter-site root scanner: gc/mod.rs adds only its reg_scanner! registration in the root-scan setup. That scanner visits per-site key and holder slots before mark propagation completes; it runs no JS and adds no relocation or callback between census pass1 and sweep entry. The synchronous-full snapshot window and both boundaries remain unchanged. Re-audited 2026-10-02 for the SPECIAL ConstFn verifier: gc/mod.rs adds only a feature-gated forwarding-helper re-export. The helper follows existing validated forwarding and runs no JS, allocation or collection. No holder, scanner registration or mark/sweep control flow is added; both census boundaries and their synchronous window remain unchanged. Re-audited 2026-10-03 for the prototype-in-shape lane: gc/mod.rs adds one reg_scanner! registration, scan_shape_prototype_words_mut, which visits the shape records' prototype words and their identity-index values as strong roots during root scanning (before mark propagation). It runs no JS, performs no GC allocation or collection, and adds no relocation or callback between census pass1 and sweep entry; both census boundaries and their synchronous window remain unchanged.", +>>>>>>> ad87de06e8e6d126951be4da56003318cd53e72b "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -358,10 +362,17 @@ "function": "run_to_completion" }, "sources": { +<<<<<<< 2572d26ad0d5a37d6987cd3bc5db9aa98191c57b "crates/perry-runtime/src/gc/census.rs": "34d3054f47720478dfe90708c8a65895cb21d1e10d83b71dcce4742e3f480a59", "crates/perry-runtime/src/gc/cycle.rs": "ecc6ff4833cd3a49ad2a4cb93df4de58032e0b0bacd07892263cc0043145c991", "crates/perry-runtime/src/gc/mod.rs": "88e7c629cc7b825f311b26890b23b8609b807d27b0a9c002baf89543ecce3f71", "crates/perry-runtime/src/gc/policy.rs": "59bd224294917f513b792398f9a399da577c9133653960a0c0b2d362cce19682", +======= + "crates/perry-runtime/src/gc/census.rs": "4a611bfe5615559642b0e6e1eaf0f25440c5e228db5302d67dba43e577e0a1b6", + "crates/perry-runtime/src/gc/cycle.rs": "9b992d27700382c84758f6f4a5574115a9862234bed2e18e0c5a3cca25c25236", + "crates/perry-runtime/src/gc/mod.rs": "68caca3c9c9d1c6b9159f731749e963f2b0a4132ca6f16cc05f03a7b9cf5ca6e", + "crates/perry-runtime/src/gc/policy.rs": "84a869e0aa09e932a4d2b6601186260129ba851b4482f29579047713c60fc95e", +>>>>>>> ad87de06e8e6d126951be4da56003318cd53e72b "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } diff --git a/test-files/test_gap_prototype_in_shape.ts b/test-files/test_gap_prototype_in_shape.ts new file mode 100644 index 0000000000..fda44c01c4 --- /dev/null +++ b/test-files/test_gap_prototype_in_shape.ts @@ -0,0 +1,133 @@ +// The [[Prototype]] as a fact of the shape: every way an ordinary object gets +// or changes its prototype, read back through every reader. +const out: string[] = []; +const log = (...a: unknown[]) => out.push(a.map((x) => String(x)).join(" ")); + +// 1. plain function constructors, prototype reassigned after instances exist +function F(this: any, v: number) { + this.v = v; +} +(F as any).prototype.get = function (this: any) { + return this.v; +}; +(F as any).prototype.kind = "old"; +const fs: any[] = []; +for (let i = 0; i < 2000; i++) fs.push(new (F as any)(i)); +const oldProto = (F as any).prototype; +(F as any).prototype = { kind: "new", tag: "new" }; +const f2 = new (F as any)(7); +// Prototype reads only: a call of a method registered on the OLD +// `F.prototype` is a separate codegen question (reported with this lane). +log("fn", fs[5].get(), f2.kind, fs[5].kind, f2.tag, fs[5].tag, fs[5] instanceof (F as any), f2 instanceof (F as any)); +log("fnproto", Object.getPrototypeOf(fs[1999]) === oldProto, Object.getPrototypeOf(f2) === (F as any).prototype); +(F as any).prototype = oldProto; +const f3 = new (F as any)(9); +log("fnrestore", fs[0] instanceof (F as any), f3 instanceof (F as any), f2 instanceof (F as any), f3.get(), f3.kind); + +// 2. inherited null / undefined values, symbol keys 2+ hops +const s1 = Symbol("s1"); +const s2 = Symbol.for("s2"); +const base: any = { n: null, u: undefined, z: 0, [s1]: "sym1", [s2]: null }; +const mid: any = Object.create(base); +mid.m = "mid"; +const leaf: any = Object.create(mid); +log("null", leaf.n === null, leaf.u === undefined, "n" in leaf, leaf.z, leaf[s1], leaf[s2] === null, s1 in leaf); +function G(this: any) {} +(G as any).prototype = leaf; +const g: any = new (G as any)(); +log("deep", g.n === null, g[s1], g.m, g[s2] === null, g instanceof (G as any), Object.getPrototypeOf(g) === leaf); + +// 3. Object.create(null) and __proto__: null +const nul: any = Object.create(null); +nul.a = 1; +const nul2: any = { __proto__: null, b: 2 }; +log("nullproto", Object.getPrototypeOf(nul) === null, Object.getPrototypeOf(nul2) === null, "toString" in nul, "toString" in nul2, nul.a, nul2.b); +const nuls: any[] = []; +for (let i = 0; i < 500; i++) { const o: any = Object.create(null); o.k = i; nuls.push(o); } +log("nulls", nuls[499].k, Object.getPrototypeOf(nuls[3]) === null, nuls[3] instanceof Object); + +// 4. __proto__ in literals and as a setter +const p1 = { hello() { return "p1"; } }; +const p2 = { hello() { return "p2"; } }; +const lit: any = { __proto__: p1, x: 1 }; +log("lit", lit.hello(), Object.getPrototypeOf(lit) === p1); +lit.__proto__ = p2; +log("litset", lit.hello(), Object.getPrototypeOf(lit) === p2, lit.x); + +// 5. setPrototypeOf on class instances and on classes +class A { who() { return "A"; } static s() { return "sA"; } } +class B { who() { return "B"; } static s() { return "sB"; } } +const a = new A(); +const a2 = new A(); +Object.setPrototypeOf(a, B.prototype); +log("cls", a.who(), a2.who(), a instanceof A, a instanceof B, a2 instanceof A); +class C2 {} +Object.setPrototypeOf(C2, B); +log("clsstatic", (C2 as any).s(), Object.getPrototypeOf(C2) === B); +Object.setPrototypeOf(a, null); +log("clsnull", Object.getPrototypeOf(a) === null, a instanceof A, typeof (a as any).who); + +// 6. functions and arrays as prototypes (identities with no serial) +function H() { return 1; } +(H as any).extra = "fnprop"; +const viaFn: any = Object.create(H); +const viaArr: any = Object.create([10, 20, 30]); +log("unique", viaFn.extra, typeof viaFn.call, viaArr[1], viaArr.length, viaFn instanceof Function, Array.isArray(viaArr)); +viaFn.own = 1; +viaFn.own2 = 2; +log("unique2", viaFn.extra, Object.getPrototypeOf(viaFn) === H); + +// 7. prototype changes across many shapes (transitions keep the prototype) +const pr = { kind: "pr" }; +const many: any[] = []; +for (let i = 0; i < 3000; i++) { + const o: any = Object.create(pr); + o["k" + (i % 7)] = i; + if (i % 3 === 0) o.extra = i; + if (i % 5 === 0) delete o.extra; + many.push(o); +} +let ok = 0; +for (const o of many) if (Object.getPrototypeOf(o) === pr && o.kind === "pr") ok++; +log("many", ok); + +// 8. instanceof across all of these +function K() {} +const k1 = new (K as any)(); +const k2 = Object.create((K as any).prototype); +const k3: any = { __proto__: (K as any).prototype }; +const k4 = {}; +Object.setPrototypeOf(k4, (K as any).prototype); +log("inst", k1 instanceof (K as any), k2 instanceof (K as any), k3 instanceof (K as any), k4 instanceof (K as any)); +Object.setPrototypeOf(k4, null); +log("inst2", k4 instanceof (K as any), (K as any).prototype.isPrototypeOf(k1), Object.prototype.isPrototypeOf(k3)); + +// 9. a prototype that itself changes shape and prototype later +const top1: any = { t: 1 }; +const top2: any = { t: 2 }; +const midp: any = Object.create(top1); +const objs: any[] = []; +for (let i = 0; i < 100; i++) objs.push(Object.create(midp)); +midp.added = "yes"; +Object.setPrototypeOf(midp, top2); +log("chain", objs[50].t, objs[50].added, Object.getPrototypeOf(objs[0]) === midp); + +// 10. garbage: many short-lived prototypes +let sum = 0; +for (let i = 0; i < 20000; i++) { + const p: any = { base: i }; + const o: any = Object.create(p); + o.own = 1; + sum += o.base + o.own; +} +log("churn", sum); +function mk(i: number) { + function Local(this: any) { this.i = i; } + (Local as any).prototype.twice = function (this: any) { return this.i * 2; }; + return new (Local as any)(); +} +let s = 0; +for (let i = 0; i < 5000; i++) s += mk(i).twice(); +log("localctor", s); + +console.log(out.join("\n")); diff --git a/test-files/test_issue_protoshape_thread_prototypes.ts b/test-files/test_issue_protoshape_thread_prototypes.ts new file mode 100644 index 0000000000..474218406e --- /dev/null +++ b/test-files/test_issue_protoshape_thread_prototypes.ts @@ -0,0 +1,30 @@ +// Prototype-in-shape across perry/thread agents: every agent mints its own +// shape records, and a record whose identity names a prototype object holds +// a pointer into ITS agent heap, so the spawner's seed must never hand one to +// a worker. Each agent builds function-constructor instances, +// Object.create(proto) and Object.create(null) objects and reads their +// prototypes back; the main thread re-checks its own afterwards. +// perry-only (perry/thread), so a behavioural test: it throws on a mismatch. +import { parallelMap } from "perry/thread"; +function P(this: any, v: number) { this.v = v; } +(P as any).prototype.twice = function (this: any) { return this.v * 2; }; +const mainProto = { tag: "main" }; +const mainObjs: any[] = []; +for (let i = 0; i < 1000; i++) { mainObjs.push(new (P as any)(i)); mainObjs.push(Object.create(mainProto)); } +const res = parallelMap([1, 2, 3, 4, 5, 6, 7, 8], (n: number) => { + function W(this: any, v: number) { this.v = v; } + (W as any).prototype.plus = function (this: any) { return this.v + 100; }; + const proto = { tag: "w" + n }; + let ok = 0; + for (let i = 0; i < 2000; i++) { + const a: any = new (W as any)(i); + const b: any = Object.create(proto); + const c: any = Object.create(null); + if (a.plus() === i + 100 && a instanceof (W as any) && b.tag === "w" + n && Object.getPrototypeOf(b) === proto && Object.getPrototypeOf(c) === null) ok++; + } + return ok; +}); +let mok = 0; +for (let i = 0; i < mainObjs.length; i += 2) if (mainObjs[i].twice() === (i / 2) * 2 && mainObjs[i + 1].tag === "main") mok++; +console.log("workers", res.join(","), "main", mok); +if (res.some((n: number) => n !== 2000) || mok !== 1000) throw new Error("prototype lost across agents"); From 4790e92d7a27fbb4f5b8eec076ae134c30ba342a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 16:11:57 +0200 Subject: [PATCH 2/2] changelog: key the prototype-in-shape fragment to PR 11815 --- ...{PENDING-prototype-in-shape.md => 11815-prototype-in-shape.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{PENDING-prototype-in-shape.md => 11815-prototype-in-shape.md} (100%) diff --git a/changelog.d/PENDING-prototype-in-shape.md b/changelog.d/11815-prototype-in-shape.md similarity index 100% rename from changelog.d/PENDING-prototype-in-shape.md rename to changelog.d/11815-prototype-in-shape.md