You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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_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.
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.
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.
Summary
An explicit
[[Prototype]]set on a non-meta-capable receiver is silently lost when the garbage collector moves that receiver.Object.getPrototypeOfthen 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.
getPrototypeOfafterJSON.parse)Temporal cells and
dyn_evalclosures reachobject_set_static_prototypethe 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
truefor all five on both lines.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:
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 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
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.