Skip to content

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

@proggeramlug

Object.setPrototypeOf is ignored by property READS on any receiver whose prototype came from Object.create(p) or new C(): the read falls back to the class registry and returns the OLD prototype's value.

Object.getPrototypeOf and in both report the new prototype correctly. Only the value read is wrong, and it is wrong silently.

Repro

function show(label: string, o: any) {
  console.log(label, "read=" + o.a, "in=" + ("a" in o),
              "proto=" + Object.getPrototypeOf(o));
}
{ const top: any = { a: 1 }; const o: any = Object.create(top);
  Object.setPrototypeOf(o, null); show("Object.create -> null", o); }
{ const top: any = { a: 1 }; const o: any = Object.create(top);
  Object.setPrototypeOf(o, {}); show("Object.create -> {}", o); }
{ function C(this: any) {} (C as any).prototype.a = 1;
  const o: any = new (C as any)();
  Object.setPrototypeOf(o, null); show("new C() -> null", o); }
{ const top: any = { a: 1 }; const o: any = {};
  Object.setPrototypeOf(o, top); Object.setPrototypeOf(o, null);
  show("literal -> top -> null", o); }

perry (v0.5.1620, main @ 80434ce, linux x86_64):

Object.create -> null   read=1         in=false proto=null
Object.create -> {}     read=1         in=false proto=[object Object]
new C() -> null         read=1         in=false proto=null
literal -> top -> null  read=undefined in=false proto=null

node 24:

Object.create -> null   read=undefined in=false proto=null
Object.create -> {}     read=undefined in=false proto=[object Object]
new C() -> null         read=undefined in=false proto=null
literal -> top -> null  read=undefined in=false proto=null

Shape of the bug

The read consults TWO prototype chains and ORs them. The recorded chain
(ObjectMeta.prototype, which setPrototypeOf writes and which
getPrototypeOf reads) is tried first; when it does not resolve the key, the
generic 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 a
NEW 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_bytes gets this right (crates/perry-runtime/src/object/native_get.rs:143-160:
a recorded prototype that is not a pointer returns None rather than falling
through to the synthetic arm). The fallback that does not stop is on the
generic path that runs after it.

Why it matters

Object.setPrototypeOf(obj, null) is the standard way to make a
dictionary-like object, and Object.create(p) is the standard way to make one
with 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.

Activity

  1. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    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/main 89dd494, PERRY_NO_AUTO_OPTIMIZE=1,
    PERRY_INHERITED_IC unset and =0 identical:

    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=NEW old=5 new=9 shared=NEW
    Object.create(P) → setPrototypeOf(o, null) all undefined all undefined — fixed
    new C() → setPrototypeOf(o, Q) old=undefined new=9 shared=NEW old=5 new=9 shared=NEW
    new C() → setPrototypeOf(o, null) all undefined all undefined — fixed

    The 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.a with a present on both P and Q and recorded the two non-null rows as
    "perry new (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 a setPrototypeOf hop 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.

  2. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    Only the explicit-null half 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 on upstream/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   true
    

    Perry contradicts itself: "a" in o says the property is not there, Object.getPrototypeOf reports 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 an in cannot 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.create receivers and on new 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, the in and getPrototypeOf together 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.

  3. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    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-targets under -D warnings, with the object:: suite at 488/0 and the inherited-read surface covered by inherited_read_cache (31 tests), proto_validity (10) and prototype_chain (11).

    One thing worth recording from landing it: #10846's chain walk copied a local top16 == 0 && bits > 0x10000 idiom — a hand-typed handle-band floor, the shape #6321 fixed. It is routed through addr_class::is_above_handle_band in the landed version rather than the literal, so the band's definition stays in one place.

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