Skip to content

fix(gc): a relocated non-object owner must keep its explicit prototype (#10493) - #10552

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/10493-residual-prototype-relocation
Closed

proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/10493-residual-prototype-relocation

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #10493. Based on main e6dcb6274.

The bug

An explicit [[Prototype]] set on a non-meta-capable receiver is silently lost when the collector moves that receiver. Object.getPrototypeOf then answers the wrong value and inherited methods disappear, with exit code 0 and no warning. Seven kinds are affected — lazy JSON array, Map, Set, Error, Promise, Date, RegExp — with arrays and ordinary objects as controls that keep theirs. Temporal cells and dyn_eval closures are in the same population by construction.

Correct before a collection, wrong after, with nothing in the program touching the objects in between. Repro and the full seven-kind table are in #10493.

Not a regression: the pre-#10381 funnel returns on the same check. Credit to CodeRabbit, which flagged the lazy-array half on #10381 after it merged; the population turned out to be wider.

Two independent holes, both fixed here

  1. The key was not rekeyed. layout_transfer reached object_static_prototype_owner_moved only below its layout-kind early return, which a GcLayoutSlotKind::None cell never passes. The entry kept naming the pre-move address.
  2. The value was never traced or rewritten. The recorded prototype was emitted as a child edge from the Array and Object arms only, so for every other kind it was neither retained across a collection nor updated when it moved.

Fixing only the first is worse than fixing neither — it converts "prototype silently lost" into "entry names a stale address". That intermediate state was measured, which is why both halves are in one commit.

The shape of the fix

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 — instead of 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, so there is one home instead of two partial ones;
  • the recorded value is emitted as a child edge ahead of the kind arms, so no arm's early return can skip it.

CodeRabbit's suggestion (migrate from the LazyArrayTape move hook) fixes one kind of seven and does not touch the value half, so this takes the early return itself as the defect.

Verification

JS repro: main prints after lazy=false error=false promise=false date=false regexp=false; with this PR it matches node exactly.

Sabotage, both reverted: removing the rekey fails both tests at "the registry entry did not follow its owner"; restricting the value visit back to arrays and objects fails them at "the recorded prototype still names its pre-collection address". Each half has its own witness.

The unit test asserts every premise before the assertion that matters — the array is nursery-born and movable, it is not meta-capable, the entry really landed in the residual registry, and getPrototypeOf resolved it before the collection — then asserts the address actually changed. Otherwise it would pass without testing anything.

Gates (fix vs base, both at e6dcb6274): outputs node-identical on all eight fixtures (protoreloc mismatches on main — that is the bug) · PERRY_GC_FROMSPACE_SCAN_ABORT=1 clean everywhere with the fcd108bfb positive control still aborting · seeded stress 20 seeds × 4 fixtures, seed_stress_failures=0 · runtime suites base 3966/1 and fix 3968/1 with the same pre-existing failure · 14 gc integration targets 13-ok/1-failed on both arms (pre-existing) · fmt · clippy 881 both, identical sets · file-size, root-holders (unchanged — the fix caches nothing), rekeyed-key-tables, store-site, addr-class and thread-local audits all match base.

Cost

+0.04 % to +0.16 % instructions on fixtures that never arm the registry; +0.68 % on a fixture that arms it and churns Errors/Maps/Dates — the registry's global mutex plus a SipHash probe per traced owner-capable cell, which is what arrays and ordinary objects have always paid.

A first draft cost +1.4 % on gc3: an inner generic function created &mut F shims in the copying scan and the funnel stopped inlining. The committed shape avoids that.

Follow-ups, deliberately not here (both in #10493)

  1. Error, Map, Set, RegExp, Promise and Date have had meta records since arch(gc) phase 1: give every exotic cell a metadata edge, move error props onto it #8891. Moving their prototypes there, as Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 Phase B did for ordinary objects, would delete their address-keyed entries and the latched per-trace mutex with them. That is a storage change across six kinds and wants its own design pass.
  2. Separately, getPrototypeOf on a registered Map or Set answers the builtin prototype before consulting the registry, so a custom prototype there is invisible to reflection even with no collection involved. Pre-existing and untouched.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where explicitly assigned prototypes could be lost when arrays, objects, Maps, Sets, Errors, Promises, Dates, RegExps, and other supported values moved during garbage collection.
    • Ensured prototype references remain valid and continue pointing to the correct values after memory relocation.
  • Tests

    • Added coverage for prototype preservation across copying garbage collection for all supported movable value types.

Ralph Küpper added 2 commits September 17, 2026 09:55
PerryTS#10362 follow-up)

Base: e6dcb62 (main, v0.5.1587).

`Object.setPrototypeOf` stores a shaped object's prototype in that object's
meta record and everything else — every receiver `meta_capable_object` turns
away — in the residual address-keyed registry (`object::prototype_chain`).
That entry owes the collector two things: a rekey when the owner's address
changes, and its value treated as a child edge so the prototype is retained and
rewritten. Both were wired to two kinds by hand: the rekey to ordinary objects
(the `ObjectOverflowFields` move hook) and to arrays (below the layout-kind
return in the relocation funnel), the value visit to the Array and Object arms
of the rewrite descriptor.

The registry's population is not those two kinds. A lazy JSON array, Map, Set,
Error, Promise, Date, RegExp or Temporal cell reaches the recorder through
`Object.setPrototypeOf`, and a closure through `dyn_eval`. Every one of those
is movable, none was rekeyed, and none had its prototype value traced. So the
entry stayed under the address the owner had just left, the dead-owner prune
dropped it on the next collection, and the prototype was gone:

    var a = JSON.parse(text);           // >= 1 KB top-level array: lazy
    Object.setPrototypeOf(a, proto);
    Object.getPrototypeOf(a) === proto  // true, then false after one minor

Reported by CodeRabbit against PerryTS#10381 for `GC_TYPE_LAZY_ARRAY`. It is older
than PerryTS#10381 — the pre-PerryTS#10381 funnel returns on the same layout-kind check —
and it is not confined to lazy arrays: a runtime survey of every movable kind
found Map, Set, Error, Promise, Date and RegExp losing the entry the same way,
with arrays and ordinary objects as the controls that kept theirs.

Both obligations now follow the registry's population, which
`prototype_chain::residual_prototype_owner_type` states once: every kind except
the four that can never be a receiver (strings and bigints are primitives, meta
records and compiled regex programs are internal).

* `gc/layout/transfer.rs` rekeys before the layout-kind return, for every owner
  kind, behind the registry's own latch. The array-arm and move-hook copies are
  deleted, so there is one home instead of two partial ones.
* `gc/layout_slot_visit.rs` emits the recorded value as a child edge ahead of
  the kind arms — no arm's early return can skip it — for the same population.
* Rekeying alone would have been worse than the bug: the entry would follow the
  owner while still naming the prototype's pre-collection address. The survey
  measured exactly that between the two halves.

`gc/tests/residual_prototype_relocation.rs` is the witness. One test drives a
nursery lazy array through the real `Object.setPrototypeOf` and a real copying
minor that provably moves both it and its prototype; the other runs every
movable owner kind with the prototype held by nothing but the registry entry,
so it also pins retention. Both fail on the parent commit, and each half of the
fix has its own sabotage: removing the rekey fails them at "the registry entry
did not follow its owner", restricting the value visit to arrays and objects
fails them at "the recorded prototype still names its pre-collection address".

instructions:u, min of 5, base vs this:
  gc3         11,755,950,680 -> 11,770,591,001  +0.12%
  w1000        1,045,688,901 ->  1,046,210,601  +0.05%
  w5000        1,886,469,457 ->  1,888,582,673  +0.11%
  w20000       4,727,860,476 ->  4,735,365,693  +0.16%
  oldyoung     1,454,672,934 ->  1,455,225,497  +0.04%
  alloc-only     320,198,430 ->    320,203,034  +0.00%
  protoreloc   1,519,990,848 ->  1,521,552,668  +0.10%  (and correct only here)
  latched      1,913,984,987 ->  1,927,027,915  +0.68%

`latched` is the priced case: a program that has re-prototyped a non-object at
all, churning Errors, Maps and Dates. Every traced cell of an owner-capable kind
then takes the registry's global mutex and a SipHash probe, which is what arrays
and ordinary objects have always paid. Removing that price means giving the
exotic cells their prototypes back in their own meta records (they all have one
since PerryTS#8891) instead of in an address-keyed table — a storage change worth its
own design pass, not this fix.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 9938ecfb-26e3-48de-aefb-1e6576ce55f4

📥 Commits

Reviewing files that changed from the base of the PR and between 193889d and 01063a8.

📒 Files selected for processing (8)
  • changelog.d/10552-residual-prototype-relocation.md
  • crates/perry-runtime/src/gc/layout/transfer.rs
  • crates/perry-runtime/src/gc/layout_slot_visit.rs
  • crates/perry-runtime/src/gc/layout_tables.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs
  • crates/perry-runtime/src/gc/types.rs
  • crates/perry-runtime/src/object/prototype_chain.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The GC now relocates residual explicit-prototype registry entries for all eligible movable owner types. Slot rewriting also retains and updates recorded prototype values. New copying-minor tests cover lazy arrays and other movable receiver kinds.

Changes

Residual prototype relocation

Layer / File(s) Summary
Residual owner classification
crates/perry-runtime/src/object/prototype_chain.rs, changelog.d/10552-residual-prototype-relocation.md
residual_prototype_owner_type centralizes eligible GC cell types and excludes strings, bigints, metadata, and compiled regex programs.
Relocation and slot tracing
crates/perry-runtime/src/gc/layout/transfer.rs, crates/perry-runtime/src/gc/layout_slot_visit.rs, crates/perry-runtime/src/gc/layout_tables.rs, crates/perry-runtime/src/gc/types.rs
layout_transfer rekeys residual prototype owners before layout classification. Slot visiting rewrites the recorded prototype before kind-specific dispatch. The move hook no longer performs a duplicate rekey.
Copying-minor coverage
crates/perry-runtime/src/gc/tests/*
New tests verify residual prototype relocation for lazy arrays, arrays, objects, Map, Set, Error, Promise, Date, and RegExp.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant CopyingMinor
  participant layout_transfer
  participant PrototypeRegistry
  participant SlotVisitor
  CopyingMinor->>layout_transfer: Move owner to new address
  layout_transfer->>PrototypeRegistry: Rekey residual prototype entry
  CopyingMinor->>SlotVisitor: Visit moved object slots
  SlotVisitor->>PrototypeRegistry: Rewrite recorded prototype address
Loading

Merge Risk: ⚪ Minimal · up to 01063

The change preserves explicit prototypes across copying-minor collection for affected receiver types, with focused coverage for rekeying and prototype retention. No actionable merge risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.25% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 7 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: preserving explicit prototypes for relocated non-object owners during garbage collection.
Description check ✅ Passed The description is detailed and covers the bug, root causes, fix, affected kinds, tests, verification results, costs, and out-of-scope follow-ups. It does not use all template headings or provide the …
Linked Issues check ✅ Passed The PR meets the coding requirements in #10493. layout_transfer now rekeys residual prototype owners before the layout-kind early return for the full eligible owner population. `visit_gc_rewrite_slo…
Out of Scope Changes check ✅ Passed The changes stay within #10493. The implementation changes only residual registry relocation and prototype-edge tracing. The tests verify the required relocation behavior. The #[inline(always)] chan…
Full details: Docstring Coverage

Explanation

Docstring coverage is 56.25% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 7 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Independent re-verification at a newer merge-base (5030e6eed6, v0.5.1590)

This PR's evidence was gathered at e6dcb6274 (v0.5.1587). I replayed the load-bearing gates with the same commit applied on top of 5030e6eed6, 22 commits later, on perrymaster (Linux x86_64, 16c). Of the seven files this PR touches, main changed exactly one line in one (gc/tests/mod.rs, an unrelated mod line), and the branch still merges cleanly.

Both arms built from that same base — base = 5030e6eed6 untouched, fix = 5030e6eed6 + 722a9c733 — so the only difference between the two binaries is the runtime archive.

Output vs node 26.5.1 (internal_ms filtered; auto-optimize on and off for the two registry-arming fixtures):

fixture base fix
protoreloc (the #10493 repro), no-auto and auto MISMATCHafter lazy=false error=false promise=false date=false regexp=false IDENTICAL
latched (no-auto and auto) IDENTICAL IDENTICAL
gc3, w1000, w5000, w20000, oldyoung, alloc IDENTICAL IDENTICAL

The bug reproduces under the default auto-optimize path as well as PERRY_NO_AUTO_OPTIMIZE=1, and the fix closes it in both.

Tests. cargo test --release -p perry-runtime -- --test-threads=1 on the fix: 3969 passed, 1 failedgc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds, which I then ran on the base arm at this same merge-base: it fails there too (heap_generation.rs:285), so it is pre-existing and not owned by this PR.

The witness can fail. Both new tests, dropped unchanged into the base tree, fail there:

test_lazy_array_explicit_prototype_survives_a_copying_minor ... FAILED
  assertion `left == right` failed: the registry entry must follow the lazy header
  to its new address and name the prototype at ITS current address
    left: None   right: Some(9222534149130289248)
test_residual_prototype_owners_of_every_movable_kind_survive_a_copying_minor ... FAILED
  lazy array: the registry entry did not follow its owner to the new address

instructions:u, 5 alternating runs, min, no-auto binaries:

fixture base fix delta
gc3 12,036,890,607 12,019,794,616 -0.142%
w1000 1,060,140,917 1,059,651,231 -0.046%
w20000 4,836,398,853 4,825,853,798 -0.218%
oldyoung 1,537,332,238 1,535,984,247 -0.088%
alloc-only 322,948,098 322,953,518 +0.002%
protoreloc 1,588,144,841 1,590,462,125 +0.146%
latched 2,363,769,576 2,379,380,673 +0.660%

latched reproduces this PR's priced case to within 0.02pp of its reported +0.68%. The unlatched fixtures came out slightly negative here rather than the +0.04…+0.16% reported at the older base — same direction of effect as the inline(always) annotations, and in any case not a regression on the axis that could have been one.

Review notes, having read the change rather than only run it — three things I checked because they are what the deleted code used to guarantee:

  1. Deleting the rekey from the ObjectOverflowFields move hook is safe only if the funnel runs on every path that reaches that hook. It does: all three gc_type_after_payload_move call sites (copying.rs:608, oldgen.rs:1506, oldgen.rs:1635) are preceded by layout_transfer on the same pair, and array/push_pop.rs:216 covers growth.
  2. Reading obj_type from the source header inside layout_transfer is sound at the two old-gen sites that call set_forwarding_address before it — forwarding overwrites the first user word, not the header's obj_type, which is exactly what the adjacent gc_type_after_payload_move((*header).obj_type, …) already relies on.
  3. The hoisted value visit reaches marking, not only the rewrite pass: trace.rs:1752's trace_heap_rewrite_slots goes through visit_gc_rewrite_slot_descriptors, so retention holds for every owner kind — which is what the second test pins by holding the prototype through nothing but the registry entry. Pointer-free kinds (Temporal) are not skipped on this path; gc_type_is_pointer_free gates only black-birth seeding and a barrier fast path.

Nothing to change from my side.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10578 (v0.5.1593). All source commits preserve authorship; merged main matches the validated train exactly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC: an explicit [[Prototype]] on a Map/Set/Error/Promise/Date/RegExp/lazy array is silently lost when the collector moves the receiver

1 participant