Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/10552-residual-prototype-relocation.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 52 additions & 16 deletions crates/perry-runtime/src/gc/layout/transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,30 +78,47 @@ 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;
};
assert_relocation_copied_the_header(old_header, new_user);

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,
Expand All @@ -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(
Expand All @@ -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
Expand Down
35 changes: 17 additions & 18 deletions crates/perry-runtime/src/gc/layout_slot_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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-
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/gc/layout_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/gc/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading