Repository navigation
GC: an explicit [[Prototype]] on a Map/Set/Error/Promise/Date/RegExp/lazy array is silently lost when the collector moves the receiver #10493
Description
Summary
An explicit [[Prototype]] set on a non-meta-capable receiver is silently lost when the garbage collector moves that receiver. Object.getPrototypeOf then answers the wrong value, and methods inherited from the custom prototype disappear — with exit code 0 and no warning.
Seven receiver kinds are affected. Arrays and ordinary objects are not: they keep theirs, and serve as controls.
| receiver | moved by a copying minor | registry entry after | getPrototypeOf after |
|---|---|---|---|
| Array | yes | kept (control) | correct |
| ordinary object | yes | kept (control, meta record) | correct |
lazy array (JSON.parse) |
yes | LOST | wrong |
| Map | yes | LOST | wrong |
| Set | yes | LOST | wrong |
| Error | yes | LOST | wrong |
| Promise | yes | LOST | wrong |
| Date | yes | LOST | wrong |
| RegExp | yes | LOST | wrong |
Temporal cells and dyn_eval closures reach object_set_static_prototype the same way and are in the same population by construction; I have not built witnesses for them.
Version: main e6dcb6274 (v0.5.1587), Linux x86_64. This is not a regression — the pre-#10381 relocation funnel returns on the same check, so it is at least as old as that code.
Credit: CodeRabbit flagged the lazy-array half on #10381 (review comment), after that PR had merged. Its reading of the code was right; the population turned out to be wider.
Reproducer
Plain JS, runs unmodified on node. Node prints true for all five on both lines.
function churn(n) {
var s = 0;
for (var k = 0; k < n; k++) { var o = { a: k, b: [k] }; s = (s + o.b[0]) | 0; }
return s;
}
var parts = [];
for (var i = 0; i < 400; i++) parts.push(i);
var text = JSON.stringify(parts); // > 1 KB top-level array: JSON.parse takes the lazy tape route
var lazy = JSON.parse(text); var protoLazy = { tag: "lazy" }; Object.setPrototypeOf(lazy, protoLazy);
var err = new Error("x"); var protoErr = { tag: "error" }; Object.setPrototypeOf(err, protoErr);
var prom = Promise.resolve(1); var protoProm = { tag: "promise" }; Object.setPrototypeOf(prom, protoProm);
var date = new Date(0); var protoDate = { tag: "date" }; Object.setPrototypeOf(date, protoDate);
var re = /a+/g; var protoRe = { tag: "regexp" }; Object.setPrototypeOf(re, protoRe);
function report(label) {
console.log(label +
" lazy=" + (Object.getPrototypeOf(lazy) === protoLazy) +
" error=" + (Object.getPrototypeOf(err) === protoErr) +
" promise=" + (Object.getPrototypeOf(prom) === protoProm) +
" date=" + (Object.getPrototypeOf(date) === protoDate) +
" regexp=" + (Object.getPrototypeOf(re) === protoRe) +
" lazy.tag=" + lazy.tag + " len=" + lazy.length);
}
report("before");
var sink = churn(3000000);
report("after");
console.log("sink=" + sink);node before lazy=true error=true promise=true date=true regexp=true lazy.tag=lazy len=400
node after lazy=true error=true promise=true date=true regexp=true lazy.tag=lazy len=400
perry before lazy=true error=true promise=true date=true regexp=true lazy.tag=lazy len=400
perry after lazy=false error=false promise=false date=false regexp=false …
Note the shape of it: correct before the collection, wrong after. Nothing in the program touches these objects in between.
Mechanism — two independent halves
A receiver that is not meta-capable keeps its explicit prototype in the residual address-keyed registry (#9304). Relocation has to do two things with such an entry, and neither happened for these kinds:
- The key is not rekeyed.
layout_transferreachesobject_static_prototype_owner_movedonly below its layout-kind early return, and aGcLayoutSlotKind::Nonecell (lazy array, Map, Set, Error, Promise, Date, RegExp) never gets there. The lazy-array move hook migrates tape state only. So the entry still names the pre-move address, and the owner has no prototype. - The value is not traced or rewritten. The registry's recorded prototype was emitted as a child edge only from the Array and Object arms of the rewrite descriptor. For every other kind the prototype was neither retained across the collection nor updated when it moved.
The two are independent, and fixing only the first is worse than fixing neither: I measured that intermediate state, and it converts "prototype silently lost" into "entry names a stale address", which is the dangling-pointer version of the same bug.
Why it took a specific repro to see
Two traps that cost me a wrong "cannot reproduce" earlier in this investigation, recorded so the next person skips them:
JSON.parseonly takes the lazy-tape route for a 1 KB–8 MB payload whose first non-whitespace byte is[, and only until traversal feedback flips it to eager. A three-elementJSON.parse("[1,2,3]")produces an ordinary array and exercises nothing.lazytape:N/MinPERRY_GC_DIAGis the from-space finalization counter — dead tapes. A live lazy array that moves never appears in it, solazytape:0/0does not mean "no lazy arrays were moved".
Fix in flight
A fix is written and gated (rekey for the registry's full population before the layout-kind return, plus the value emitted as a child edge ahead of the kind arms) and will be posted as a PR against this issue shortly. Cost: +0.04% to +0.16% instructions on fixtures that never arm the registry, +0.68% on one that arms it and churns Errors/Maps/Dates — the same per-owner cost arrays and objects have always paid.
Two follow-ups this exposed, not in that fix
- Storage, not relocation. 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 — rather than making relocation carry them. That is a storage change across six kinds and wants its own design pass.
- A separate, non-GC divergence:
getPrototypeOfon 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. Untouched by the fix above.
Activity