Skip to content

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

@proggeramlug

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:

  1. The key is not rekeyed. layout_transfer reaches object_static_prototype_owner_moved only below its layout-kind early return, and a GcLayoutSlotKind::None cell (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.
  2. 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.parse only 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-element JSON.parse("[1,2,3]") produces an ordinary array and exercises nothing.
  • lazytape:N/M in PERRY_GC_DIAG is the from-space finalization counter — dead tapes. A live lazy array that moves never appears in it, so lazytape:0/0 does 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

  1. 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.
  2. A separate, non-GC divergence: 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. Untouched by the fix above.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions