Repository navigation
Object.setPrototypeOf is ignored by property reads on an Object.create()/new C() receiver: the read falls back to the class registry and returns the old prototype's value #10827
Description
Activity
The null half of this is fixed on main (PR #10846 /
655fdb3e3, train 250,
v0.5.1629) — confirmed. The non-null half is not, and the reason it was
triaged as correct is worth recording, because the probe that cleared it could
not have detected the defect.Re-measured on
upstream/main89dd494,PERRY_NO_AUTO_OPTIMIZE=1,
PERRY_INHERITED_ICunset and=0identical:function row(tag, o) { console.log(tag + ": old=" + String(o.od) + " new=" + String(o.nw) + " shared=" + String(o.sh)); } const OLD = { od: 5, sh: "OLD" }; const NEWP = { nw: 9, sh: "NEW" }; const a = Object.create(OLD); Object.setPrototypeOf(a, NEWP); row("create->setProto(Q)", a); const b = Object.create(OLD); Object.setPrototypeOf(b, null); row("create->setProto(null)", b); class C { } C.prototype.od = 5; C.prototype.sh = "OLD"; const c = new C(); Object.setPrototypeOf(c, NEWP); row("new C->setProto(Q)", c); const d = new C(); Object.setPrototypeOf(d, null); row("new C->setProto(null)", d);
case node perry main@250 Object.create(P)→setPrototypeOf(o, Q)old=undefined new=9 shared=NEWold=5new=9 shared=NEWObject.create(P)→setPrototypeOf(o, null)all undefinedall undefined— fixednew C()→setPrototypeOf(o, Q)old=undefined new=9 shared=NEWold=5new=9 shared=NEWnew C()→setPrototypeOf(o, null)all undefinedall undefined— fixedThe old prototype is never detached; the new one merely wins. A key that
lives on BOTH prototypes reads correctly (shared=NEW), and a key that lives
only on the NEW one reads correctly (nw=9) — so any probe built from keys
present on the new prototype shows a clean bill of health. Only a key present
exclusively on the replaced prototype exposes it, and then the read returns
the value from a prototype that is no longer in the chain.The object-model design note that scoped #10846 to the null case tested
o.awithapresent on both P and Q and recorded the two non-null rows as
"perrynew(right)". They are not right; they are a race the new prototype
happens to win. This is the campaign's own rule — verifying that something
fires where it must is not verifying that the thing it replaced stopped firing
— and the fixture that catches it is one extra key.It also reaches a mid-chain hop, not just the receiver, so it is not
receiver-local:const M = Object.create(OLD); Object.setPrototypeOf(M, NEWP); let E = M; for (let q = 0; q < 3; q++) E = Object.create(E); const B = Object.create(E); // node: B.nw = 9, B.od = undefined // perry: B.nw = 9, B.od = 5
Found by a differential fixture (
xsp) written for the #10877 chain-walk lane,
which needed to know whether asetPrototypeOfhop is safe to walk by class id.
It is not: the class registry still holds the old parent link, which is exactly
the mechanism this issue's title names. Noted there, and the #10877 change
delegates any hop carrying an object-level[[Prototype]]to the generic getter
rather than walking past it, so it neither fixes nor worsens this.Only the explicit-
nullhalf is fixed. The non-null half is still live on main, and the probe that cleared it could not fail.#10846 landed in train 250 and correctly fixes
setPrototypeOf(o, null). Replacing a prototype with a different prototype is still wrong, verified onupstream/main@ 89dd494 (v0.5.1629):const P1 = { a: 11 }; const P2 = { b: 22 }; const o = Object.create(P1); Object.setPrototypeOf(o, P2); console.log(o.a, "a" in o, Object.getPrototypeOf(o) === P2);
node: undefined false true perry: 11 false truePerry contradicts itself:
"a" in osays the property is not there,Object.getPrototypeOfreports the new prototype correctly, and the read returns the old prototype's value anyway. Same oracle as the original report — no comparison against node is needed to see that a read and anincannot both be right.Why it survived the fix
The old prototype is never detached — it is only outranked. When the replacement carries the key, the new prototype wins and the stale link is invisible. It only shows when the key exists solely on the replaced prototype.
The probe that cleared it could not fail
The verification for the original fix (recorded as §12.6 of the object-model design doc) tested a key present on both prototypes. In that fixture "the new prototype wins" and "the old prototype is gone" produce identical output, so the probe was structurally incapable of detecting a surviving stale link. It passed, and the non-null half was recorded as covered.
That is this campaign's recurring failure in its cleanest form: a check that cannot fail is not a check. The fixture needed a key present only on the replaced prototype.
Scope to re-verify
Reproduces on
Object.createreceivers and onnew C()receivers, and at a mid-chain hop (replacing a prototype that is not the receiver's immediate parent), not only at the receiver's own link. A fix should be tested with a key that exists only on the replaced prototype in each of those three positions, and each test should print the read, theinandgetPrototypeOftogether so a self-contradiction is visible without node.Found by a fixture built for unrelated work (#10877's chain-walk depth set), which failed on the base arm — the failure was not depth-related and the fixture was not designed to find it.
Fixed in v0.5.1630 (merge train 250,
89dd494429) by #10846 —fix(#10827): an explicitly ended prototype chain ends the READ too.Train validation: the union of #10846, #10842 and #10843 passed twelve gates including
cargo check --workspace --all-targetsunder-D warnings, with theobject::suite at 488/0 and the inherited-read surface covered byinherited_read_cache(31 tests),proto_validity(10) andprototype_chain(11).One thing worth recording from landing it: #10846's chain walk copied a local
top16 == 0 && bits > 0x10000idiom — a hand-typed handle-band floor, the shape #6321 fixed. It is routed throughaddr_class::is_above_handle_bandin the landed version rather than the literal, so the band's definition stays in one place.
Object.setPrototypeOfis ignored by property READS on any receiver whose prototype came fromObject.create(p)ornew C(): the read falls back to the class registry and returns the OLD prototype's value.Object.getPrototypeOfandinboth report the new prototype correctly. Only the value read is wrong, and it is wrong silently.Repro
perry (v0.5.1620,
main@ 80434ce, linux x86_64):node 24:
Shape of the bug
The read consults TWO prototype chains and ORs them. The recorded chain
(
ObjectMeta.prototype, whichsetPrototypeOfwrites and whichgetPrototypeOfreads) is tried first; when it does not resolve the key, thegeneric getter falls back to the receiver's synthetic class id and walks
class_prototype_object(class_id)— the prototype the receiver was BORN with.That is why the last row is correct and the first three are not: a plain object
literal has no class-registry prototype to fall back to, while an
Object.create(p)/new C()receiver does. It is also why retargeting to aNEW prototype that HAPPENS to carry the key looks like it works — the recorded
chain answers first and the stale fallback is never reached.
native_get::try_data_get_bytesgets this right (crates/perry-runtime/src/object/native_get.rs:143-160:a recorded prototype that is not a pointer returns
Nonerather than fallingthrough to the
syntheticarm). The fallback that does not stop is on thegeneric path that runs after it.
Why it matters
Object.setPrototypeOf(obj, null)is the standard way to make adictionary-like object, and
Object.create(p)is the standard way to make onewith a prototype; the combination silently returns values from an object the
program has explicitly detached. Nothing throws and nothing is logged.
Found while building the inherited-read cache (lane 3 of the object-model
single-path work); the cache itself declines every case above, so the behaviour
is identical with
PERRY_INHERITED_IC=0.