diff --git a/changelog.d/10552-residual-prototype-relocation.md b/changelog.d/10552-residual-prototype-relocation.md new file mode 100644 index 0000000000..5cc6654e99 --- /dev/null +++ b/changelog.d/10552-residual-prototype-relocation.md @@ -0,0 +1,9 @@ +### Fixed + +- **A relocated non-object owner lost its explicit `[[Prototype]]` (#10493).** `Object.setPrototypeOf` on a receiver that is not meta-capable records the prototype in the residual address-keyed registry (#9304), and relocation owes that entry two things. Neither happened outside arrays and ordinary objects: `layout_transfer` reached the rekey only *below* its layout-kind early return, which a `GcLayoutSlotKind::None` cell never passes, and the recorded value was emitted as a child edge from the Array and Object arms alone. So a lazy JSON array, Map, Set, Error, Promise, Date, RegExp, Temporal cell or `dyn_eval` closure silently lost its prototype at its first relocation — correct before a collection, wrong after, exit code 0 and no warning — and the prototype value itself was neither retained nor rewritten. Fixing only the rekey is worse than fixing neither: it turns "prototype lost" into "entry names a stale address", a state measured between the two halves. + + Both obligations now follow the registry's population, stated once in `prototype_chain::residual_prototype_owner_type` (everything except strings, bigints, meta records and compiled regex programs) rather than being wired to two kinds by hand. The rekey runs before the layout-kind return for every owner kind, latch-gated, with the move in a `#[cold]` call; the array-arm and move-hook copies are deleted. The recorded value is emitted ahead of the kind arms, so no arm's early return can skip it. + + Each half has its own sabotage witness: removing the rekey fails at "the registry entry did not follow its owner", and restricting the value visit back to arrays and objects fails at "the recorded prototype still names its pre-collection address". Cost is +0.04% to +0.16% instructions where the registry is never armed, and +0.68% on a fixture that arms it and churns Errors/Maps/Dates — the per-owner cost arrays and objects have always paid. + + Not a regression: the funnel before #10381 returns on the same check. Found from a CodeRabbit review comment on #10381 that landed unactioned; the population turned out to be six kinds wider than the one it named. diff --git a/crates/perry-runtime/src/gc/layout/transfer.rs b/crates/perry-runtime/src/gc/layout/transfer.rs index b99cce0cf2..e8eb7bbd98 100644 --- a/crates/perry-runtime/src/gc/layout/transfer.rs +++ b/crates/perry-runtime/src/gc/layout/transfer.rs @@ -17,13 +17,27 @@ //! `GC_OBJ_TYPED_LAYOUT_INTACT`. What cannot ride a header is a record keyed //! by the object's ADDRESS, and that is all this funnel moves: //! +//! * the residual static-prototype owner registry (#9304), gated by its +//! process-global latch (#7733/#7737) — for EVERY kind that can own an +//! entry, not only the layout kinds (see below); //! * the element-shape proof record (#7480), gated by the header bit that is //! authoritative for it; -//! * the residual static-prototype owner registry (#9304), gated by its -//! process-global latch (#7733/#7737); //! * the per-object `TYPED_LAYOUTS` and `LAYOUT_SLOT_MASKS` entries, gated by //! #7510's emptiness flag and address filter. //! +//! # The prototype registry is not layout metadata +//! +//! Its population is every receiver `object::prototype_chain:: +//! meta_capable_object` turns away — a Map, Set, Error, Promise, Date, RegExp, +//! Temporal cell, lazy JSON array or closure as much as an array — and most of +//! those kinds have no layout slots at all. The rekey used to sit in the array +//! arm of the record path, below the layout-kind return, and in the ordinary +//! object's move hook; every other movable owner kept its entry under the +//! address it had just left, and the dead-owner prune then dropped it. So it +//! runs first, keyed on `prototype_chain::residual_prototype_owner_type`, the +//! same population predicate the collector's value visit uses +//! (`gc/layout_slot_visit.rs`). +//! //! Until #10362 the funnel re-derived the header half too — rewriting bits //! that were already equal, and re-resolving the intact bit through a //! ShapeId-keyed `SHAPE_LAYOUTS` probe — once per relocated object. Measured @@ -64,14 +78,33 @@ use crate::gc::layout_tables::per_object_layouts_may_hold_either; /// `old_user` and `new_user` are user pointers of live allocations, and the /// caller has already made the destination header a copy of the source's (see /// the module docs). The precondition is asserted in test and debug builds. -#[inline] +/// +/// `inline(always)`: every relocation of every object runs this and all it +/// keeps inline is gates — each record move is a cold out-of-line call — but +/// three gates are enough for the heuristic to outline it from `move_young`, +/// and then the call costs more than the gates. +#[inline(always)] pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { if old_user.is_null() || new_user.is_null() || old_user == new_user { return; } + if (old_user as usize) < GC_HEADER_SIZE + 0x1000 { + return; + } + // Before the layout-kind return, because the registry's owners are not the + // layout kinds (module docs). The latch first: it is one byte load, false + // for any process that never re-prototyped a non-object, and the move is + // out of line. + if crate::object::prototype_chain::object_static_prototypes_maybe_nonempty() + && crate::object::prototype_chain::residual_prototype_owner_type( + (*header_from_user_ptr(old_user as *const u8)).obj_type, + ) + { + transfer_residual_prototype(old_user as usize, new_user as usize); + } // Kinds with no layout metadata at all (strings, meta records, RegExps) - // leave before anything else, exactly as before #10362. The destination - // carries the same `obj_type`, so one classification answers for both. + // have no layout record to move. The destination carries the same + // `obj_type`, so one classification answers for both. let Some(old_header) = layout_header_for_user(old_user as usize) else { return; }; @@ -79,15 +112,13 @@ pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { let reserved = (*old_header)._reserved; let is_array = (*old_header).obj_type == GC_TYPE_ARRAY; - // Three gates, all answered from words already in registers or in the one + // Two gates, both answered from words already in registers or in the one // hot thread-local slot #7510 keeps them in. Each is the same question the // record mover behind it asks first, hoisted so the common case — no // record anywhere near either address — never leaves this function. let per_object = per_object_layouts_may_hold_either(old_user as usize, new_user as usize); let element_shape = is_array && reserved & GC_ARRAY_ELEMENT_SHAPE != 0; - let static_prototype = - is_array && crate::object::prototype_chain::object_static_prototypes_maybe_nonempty(); - if per_object || element_shape || static_prototype { + if per_object || element_shape { transfer_address_keyed_records( old_user as usize, new_user as usize, @@ -102,9 +133,18 @@ pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { header_clear_typed_layout_intact(old_header); } -/// The record moves themselves. Cold: on a workload holding no per-object -/// layout record, no element-shape proof and no re-prototyped array — the -/// steady state of every monomorphic program — it is never reached. +/// The residual prototype registry's rekey. Cold and out of line so the funnel +/// stays small enough to inline into every relocation site: it is reached only +/// once something in the process has been re-prototyped. +#[cold] +#[inline(never)] +fn transfer_residual_prototype(old_user: usize, new_user: usize) { + crate::object::prototype_chain::object_static_prototype_owner_moved(old_user, new_user); +} + +/// The layout record moves themselves. Cold: on a workload holding no +/// per-object layout record and no element-shape proof — the steady state of +/// every monomorphic program — it is never reached. #[cold] #[inline(never)] unsafe fn transfer_address_keyed_records( @@ -119,10 +159,6 @@ unsafe fn transfer_address_keyed_records( // from both headers and fails closed — it clears the destination bit // when no record follows the move. crate::array::transfer_element_shape(old_user, new_user); - // #9304: a real array keeps an explicit [[Prototype]] in the residual - // address-keyed registry; moving GC and growth both replace the owner - // allocation through this hook. - crate::object::prototype_chain::object_static_prototype_owner_moved(old_user, new_user); } // #7510's two per-object maps. Both re-test the gate above for their own // address pair, so calling them when only a sibling gate fired costs one diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 68ef1bccb0..d76398e700 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -155,7 +155,24 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( if header.is_null() || (*header).gc_flags & GC_FLAG_FORWARDED != 0 { return; } + let obj_type = (*header).obj_type; let user_ptr = (header as *mut u8).add(GC_HEADER_SIZE); + // An explicit `Object.setPrototypeOf` value recorded in the residual + // registry is a child edge of its owner, whatever the owner's kind: marking + // retains it and a moving collection rewrites it after + // `gc/layout/transfer.rs` rekeyed the entry. It used to be emitted from the + // array and ordinary-object arms only, so a Map, Set, Error, Promise, Date, + // RegExp, Temporal cell, lazy JSON array or closure owner kept a stale + // prototype address once the prototype moved. First, ahead of the kind + // arms, so no arm's early return can skip it. + if crate::object::prototype_chain::object_static_prototypes_maybe_nonempty() + && crate::object::prototype_chain::residual_prototype_owner_type(obj_type) + { + crate::object::prototype_chain::visit_object_static_prototype_slot_mut( + user_ptr as usize, + |slot| visit(fixed_slot(slot)), + ); + } match gc_type_rewrite_descriptor_kind((*header).obj_type) { GcRewriteDescriptorKind::Array => { visit_gc_layout_slot_descriptors(header, &mut visit); @@ -172,16 +189,6 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( |slot| visit(fixed_slot(slot)), ); } - // #9304: unlike shaped objects, real arrays keep an explicit - // [[Prototype]] in the residual side table. Treat that value as - // the array's child edge so collection retains and rewrites a - // movable custom prototype after layout_transfer rekeys its owner. - crate::object::prototype_chain::visit_object_static_prototype_slot_mut( - user_ptr as usize, - |slot| { - visit(fixed_slot(slot)); - }, - ); } GcRewriteDescriptorKind::Object => { // #6759 Phase B / #6812: the per-object meta record is a raw- @@ -195,14 +202,6 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( crate::object::visit_overflow_field_slots_mut(user_ptr as usize, |slot| { visit(fixed_slot(slot)); }); - // #2820: the recorded `Object.setPrototypeOf` value is a live - // reference; rewrite it if the prototype object moved. - crate::object::prototype_chain::visit_object_static_prototype_slot_mut( - user_ptr as usize, - |slot| { - visit(fixed_slot(slot)); - }, - ); } GcRewriteDescriptorKind::RegExp => { visit_gc_layout_slot_descriptors(header, &mut visit); diff --git a/crates/perry-runtime/src/gc/layout_tables.rs b/crates/perry-runtime/src/gc/layout_tables.rs index dc006c52c8..d429ab4c7f 100644 --- a/crates/perry-runtime/src/gc/layout_tables.rs +++ b/crates/perry-runtime/src/gc/layout_tables.rs @@ -1042,7 +1042,7 @@ pub(in crate::gc) fn per_object_layouts_maybe_nonempty() -> bool { /// where the two `transfer_*` entry points below each resolve the hot slot /// again for their own pair (#10362). Same answer, one thread-local /// resolution: the flag and the filter live in the same slot. -#[inline] +#[inline(always)] pub(in crate::gc) fn per_object_layouts_may_hold_either(old_user: usize, new_user: usize) -> bool { let hint = hot_per_object_layout_hint(); hint.nonempty.get() && (hint_may_hold(hint, old_user) || hint_may_hold(hint, new_user)) diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 1ffdcb9893..b62c25aa63 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -59,6 +59,7 @@ mod os_tag; mod promote_in_place; mod promoted_cohort; mod proxy_registry; +mod residual_prototype_relocation; mod restore_coverage; mod retention_9628_9629; mod root_words; diff --git a/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs new file mode 100644 index 0000000000..2340ea7f0a --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs @@ -0,0 +1,282 @@ +//! An explicit `[[Prototype]]` recorded in the residual registry must survive +//! its owner's relocation, whatever the owner's kind. +//! +//! `Object.setPrototypeOf` keeps a shaped object's prototype in its meta record +//! and everything else — every receiver `meta_capable_object` turns away — in +//! the residual address-keyed registry (`object::prototype_chain`). That entry +//! carries two collector obligations: rekey it when the owner moves, and treat +//! its value as a child edge so the prototype is retained and rewritten. Both +//! were wired to arrays and ordinary objects only, the rekey below +//! `layout_transfer`'s layout-kind return. Every other movable owner — a lazy +//! JSON array, Map, Set, Error, Promise, Date, RegExp — lost its prototype at +//! its first copying minor, and with the rekey alone would have kept a stale +//! address to a prototype that had moved or died. +//! +//! These tests have to be able to fail, so every premise they rest on — the +//! entry landed in the registry, the owner and the prototype really moved — is +//! asserted before the verdict. + +use super::super::*; +use super::support::*; + +/// Retargeting any array-like latches the process-wide "an array somewhere has +/// a custom `[[Prototype]]`" flag and stands the index fast paths down for the +/// rest of the binary (see `ArrayPrototypeLatchGuard` in `dyn_eval/tests.rs`). +/// Restore what the test found. +struct ArrayPrototypeLatchRestore { + latch: bool, + invalidated: u8, +} + +impl ArrayPrototypeLatchRestore { + fn capture() -> Self { + Self { + latch: crate::object::prototype_chain::array_static_proto_recorded(), + invalidated: crate::array::PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED + .load(std::sync::atomic::Ordering::Relaxed), + } + } +} + +impl Drop for ArrayPrototypeLatchRestore { + fn drop(&mut self) { + crate::object::prototype_chain::test_swap_array_static_proto_recorded(self.latch); + crate::array::test_swap_array_index_fast_path_invalidated(self.invalidated); + } +} + +/// A small `JSON.parse`-shaped lazy array: small enough that its cluster is +/// born in the nursery (`json_tape::lazy_cluster_is_old`), so a copying minor +/// moves it. +fn nursery_lazy_array(input: &[u8]) -> usize { + let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + crate::json_tape::with_built_tape(input, |tape| unsafe { + crate::json_tape::alloc_lazy_array( + tape, + 0, + crate::json_tape::count_array_length(tape, 0), + text, + ) + }) + .expect("valid JSON should build a tape") as usize +} + +fn obj_type_at(user: usize) -> u8 { + unsafe { (*header_from_user_ptr(user as *const u8)).obj_type } +} + +const MARKER: f64 = 10362.0; + +fn marked_prototype() -> usize { + let proto = crate::object::js_object_alloc(0, 1); + crate::object::js_object_set_field(proto, 0, crate::value::JSValue::number(MARKER)); + proto as usize +} + +fn forget_owners(owners: &[usize]) { + crate::object::prototype_chain::prune_dead_object_prototype_owners(&|owner| { + owners.contains(&owner) + }); +} + +/// The claim as reported against #10381: a lazy array's prototype, set through +/// the real `Object.setPrototypeOf` entry, across a copying minor that moves +/// both the array and its prototype. +#[test] +fn test_lazy_array_explicit_prototype_survives_a_copying_minor() { + let _serialized = crate::array::test_serialize(); + let _feedback = crate::typed_feedback::typed_feedback_test_lock(); + let _latch = ArrayPrototypeLatchRestore::capture(); + // Two rooted values — the lazy array and its prototype — so two shadow + // slots: a store outside the pushed frame is a silent no-op (#7184). + let _guard = CopyingNurseryTestGuard::new(2); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let lazy = nursery_lazy_array(b"[1,2,3]"); + assert_eq!( + obj_type_at(lazy), + GC_TYPE_LAZY_ARRAY, + "premise: a real lazy array" + ); + assert!( + crate::arena::pointer_in_nursery(lazy), + "premise: the lazy cluster is nursery-born, so a copying minor can move it" + ); + assert!(crate::gc::gc_type_is_movable(GC_TYPE_LAZY_ARRAY)); + assert!( + unsafe { crate::object::prototype_chain::meta_capable_object(lazy) }.is_none(), + "premise: a lazy array has no meta record, so its prototype goes to the registry" + ); + + let proto = marked_prototype(); + js_shadow_slot_set(0, ptr_bits(lazy)); + js_shadow_slot_set(1, ptr_bits(proto)); + + // The real user-facing entry, not the recorder beneath it. + crate::object::js_object_set_prototype_of( + f64::from_bits(ptr_bits(lazy)), + f64::from_bits(ptr_bits(proto)), + ); + let lazy = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + let proto = (js_shadow_slot_get(1) & POINTER_MASK) as usize; + assert_eq!( + obj_type_at(lazy), + GC_TYPE_LAZY_ARRAY, + "premise: setPrototypeOf left the receiver a lazy array" + ); + assert!( + crate::object::prototype_chain::test_prototype_registry_latch_armed(), + "premise: the residual registry holds an entry" + ); + assert_eq!( + crate::object::prototype_chain::object_static_prototype(lazy), + Some(ptr_bits(proto)), + "premise: the prototype is recorded in the residual registry under the lazy \ + header's own address" + ); + assert_eq!( + crate::object::js_object_get_prototype_of(f64::from_bits(ptr_bits(lazy))).to_bits(), + ptr_bits(proto), + "premise: Object.getPrototypeOf resolves it before anything moves" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let lazy_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + let proto_after = (js_shadow_slot_get(1) & POINTER_MASK) as usize; + assert_ne!( + lazy_after, lazy, + "the copying minor must actually relocate the lazy header — an unmoved \ + receiver proves nothing" + ); + assert_ne!( + proto_after, proto, + "the prototype must move too, or a stale recorded address would go unseen" + ); + assert_eq!(obj_type_at(lazy_after), GC_TYPE_LAZY_ARRAY); + + let recorded = crate::object::prototype_chain::object_static_prototype(lazy_after); + let resolved = + crate::object::js_object_get_prototype_of(f64::from_bits(ptr_bits(lazy_after))).to_bits(); + // Leave no entry behind for a later test to trip over, whatever the verdict. + forget_owners(&[lazy, lazy_after]); + + assert_eq!( + recorded, + Some(ptr_bits(proto_after)), + "the registry entry must follow the lazy header to its new address and name \ + the prototype at ITS current address" + ); + assert_eq!( + resolved, + ptr_bits(proto_after), + "Object.getPrototypeOf on the relocated lazy array must still return the \ + prototype it was given" + ); +} + +/// Every movable receiver kind that keeps its prototype in the residual +/// registry, with the prototype held by NOTHING but that entry. One copying +/// minor has to rekey the entry, retain the prototype through it, and rewrite +/// the recorded address — the three obligations the registry's population +/// shares regardless of kind. Arrays and ordinary objects are the controls: +/// they were covered before. +#[test] +fn test_residual_prototype_owners_of_every_movable_kind_survive_a_copying_minor() { + let _serialized = crate::array::test_serialize(); + let _feedback = crate::typed_feedback::typed_feedback_test_lock(); + let _latch = ArrayPrototypeLatchRestore::capture(); + // One rooted owner at a time; its prototype is deliberately unrooted. + let _guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + type Alloc = Box usize>; + let mut owners: Vec<(&str, Alloc)> = vec![ + ( + "array", + Box::new(|| crate::array::js_array_alloc(4) as usize), + ), + ( + "object", + Box::new(|| crate::object::js_object_alloc(0, 1) as usize), + ), + ("lazy array", Box::new(|| nursery_lazy_array(b"[1,2,3]"))), + ("Map", Box::new(|| crate::map::js_map_alloc(0) as usize)), + ("Set", Box::new(|| crate::set::js_set_alloc(0) as usize)), + ("Error", Box::new(|| crate::error::js_error_new() as usize)), + ( + "Promise", + Box::new(|| crate::promise::js_promise_new() as usize), + ), + ( + "Date", + Box::new(|| (crate::date::js_date_new().to_bits() & POINTER_MASK) as usize), + ), + ]; + #[cfg(feature = "regex-engine")] + owners.push(( + "RegExp", + Box::new(|| { + let pattern = crate::string::js_string_from_bytes(b"a+".as_ptr(), 2); + let flags = crate::string::js_string_from_bytes(b"g".as_ptr(), 1); + crate::regex::js_regexp_new(pattern, flags) as usize + }), + )); + + for (kind, alloc) in owners { + let owner = alloc(); + let obj_type = obj_type_at(owner); + assert!( + crate::arena::pointer_in_nursery(owner) && crate::gc::gc_type_is_movable(obj_type), + "{kind}: premise: a nursery owner of a movable kind" + ); + js_shadow_slot_set(0, ptr_bits(owner)); + let proto = marked_prototype(); + crate::object::js_object_set_prototype_of( + f64::from_bits(ptr_bits(owner)), + f64::from_bits(ptr_bits(proto)), + ); + let owner = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_eq!( + crate::object::prototype_chain::object_static_prototype(owner), + Some(ptr_bits(proto)), + "{kind}: premise: the prototype is recorded for this owner" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let owner_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!( + owner_after, owner, + "{kind}: premise: the owner must actually move" + ); + + let recorded = crate::object::prototype_chain::object_static_prototype(owner_after); + forget_owners(&[owner, owner_after]); + js_shadow_slot_set(0, 0); + + let Some(recorded) = recorded else { + panic!("{kind}: the registry entry did not follow its owner to the new address"); + }; + let proto_after = (recorded & POINTER_MASK) as usize; + assert_ne!( + proto_after, proto, + "{kind}: the recorded prototype still names its pre-collection address — the \ + prototype was either not retained through the entry or not rewritten" + ); + assert!( + crate::arena::pointer_in_nursery(proto_after) + || crate::arena::pointer_in_old_gen(proto_after), + "{kind}: the recorded prototype must be a live heap address" + ); + let marker = crate::object::js_object_get_field( + proto_after as *const crate::object::ObjectHeader, + 0, + ); + assert_eq!( + marker.bits(), + MARKER.to_bits(), + "{kind}: the recorded address must hold the prototype that was set" + ); + } +} diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index b81e84120c..835cb92074 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -917,10 +917,9 @@ pub(crate) fn gc_type_after_payload_move(obj_type: u8, old_user: usize, new_user GcMoveHookKind::None => {} GcMoveHookKind::ObjectOverflowFields => { crate::object::overflow_fields_owner_moved(old_user, new_user); - // #2820: migrate any recorded `Object.setPrototypeOf` entry for - // this ordinary object so getPrototypeOf/inherited reads still - // resolve after evacuation. - crate::object::prototype_chain::object_static_prototype_owner_moved(old_user, new_user); + // The residual `Object.setPrototypeOf` registry is rekeyed by the + // relocation funnel for every owner kind (`gc/layout/transfer.rs`), + // which runs before this hook on every move. crate::object::module_wrapper_owner_moved(old_user, new_user); } GcMoveHookKind::ClosureDynamicProps => { diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index c61ab25901..3376c3a07b 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -511,6 +511,34 @@ pub(crate) fn object_static_prototypes_maybe_nonempty() -> bool { OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) } +/// Can a cell of `obj_type` own an entry in the residual registry? +/// +/// The registry's population is every owner [`meta_capable_object`] turns +/// away, and the recorder is reached with whatever the caller holds: +/// `Object.setPrototypeOf` with an array, lazy JSON array, Map, Set, Error, +/// Promise, Date, RegExp or Temporal cell, `dyn_eval` with a closure. Only the +/// kinds that can never be a receiver stand outside it: strings and bigints +/// are primitives, meta records and compiled regex programs are internal. +/// `GC_TYPE_OBJECT` stays inside — its prototypes live in its meta record, and +/// the registry's obligations were always met for it too. +/// +/// The collector keys both of the registry's per-owner obligations on this +/// one predicate: the relocation rekey (`gc/layout/transfer.rs`) and the +/// value visit (`gc/layout_slot_visit.rs`). Both used to be wired to arrays +/// and ordinary objects by hand, so every other movable owner lost its +/// explicit prototype at its first relocation, and none had the prototype +/// value traced or rewritten. +#[inline] +pub(crate) fn residual_prototype_owner_type(obj_type: u8) -> bool { + !matches!( + obj_type, + crate::gc::GC_TYPE_STRING + | crate::gc::GC_TYPE_BIGINT + | crate::gc::GC_TYPE_OBJECT_META + | crate::gc::GC_TYPE_REGEX_PROGRAM + ) +} + /// Migrate the residual side-table entry when an owner's allocation address /// changes, either through moving GC or an `ArrayHeader` growth replacement. /// Mirrors `closure_dynamic_props_owner_moved`.