Skip to content

correctness: reading an inherited Object.prototype member as a value returns undefined until the realm is populated (calls work; silent and order-dependent) #10689

Description

@proggeramlug

Summary

Reading an inherited Object.prototype member as a value from a plain object returns undefined until something else has forced the globalThis realm to populate. Calling the same member works. There is no error — the program gets a wrong answer and continues.

const o = { a: 1 };
console.log(typeof o.constructor);     // perry: undefined   node: function
console.log(typeof o.toString);        // perry: undefined   node: function
console.log(typeof o.hasOwnProperty);  // perry: undefined   node: function

But every call through the same chain is correct:

const o = { a: 1 };
console.log(o.toString());             // perry: [object Object]   node: [object Object]  ✓
console.log(o.hasOwnProperty("a"));    // perry: true              node: true             ✓
console.log("" + o);                   // perry: [object Object]   node: [object Object]  ✓
console.log(`${o}`);                   // perry: [object Object]   node: [object Object]  ✓

And the prototype link itself is intact:

Object.getPrototypeOf(o) === Object.prototype                      // true  ✓
"constructor" in o                                                  // true  ✓
Object.prototype.hasOwnProperty.call(Object.prototype, "constructor") // true ✓

So: the chain is correct, membership tests are correct, calls are correct. Only the value-read path is wrong.

It is ordering-dependent, which is what makes it dangerous

const o = { a: 1 };
JSON.stringify(o);                     // forces realm population
console.log(typeof o.toString, typeof o.constructor);  // perry: function function  ✓

Anything that forces populate_global_this_builtins repairs it for the rest of the process. JSON.stringify does. Merely evaluating Object or Object.prototype does not:

const x = Object.prototype;
const o = { a: 1 };
console.log(typeof o.toString, typeof o.constructor);  // perry: undefined undefined   node: function function

That means the same expression yields different results depending on what ran earlier in the process — including code in an unrelated module. A library that feature-detects at import time gets one answer; the same check after the first JSON.stringify anywhere gets another.

Class instances and arrays are unaffected — new C().constructor === C and [].constructor === Array are both correct.

Why this matters more than a typical divergence

Value-reads of constructor and toString are how a great deal of library code decides what something is — plain-object checks, clone/merge helpers, serializer dispatch, duck-typing. Those checks do not throw when they get undefined; they take the other branch. The failure is silent and order-dependent, which is the hardest combination to attribute from a bug report downstream.

Relationship to #10686, and a warning about fixing them together

Both are consequences of the same lazy populate_global_this_builtins. They pull in opposite directions and must not be fixed with one reflex.

A change that makes the population check lazier in general would make this bug worse, and a change that makes everything force would undo #10686. Whoever takes either should state which reads force and which do not, and this pair should be a test.

Environment

perry built from main 68a545439 + #10611, on Linux x86-64. Compared against node v26.8.1. Every case above was run in both runtimes.

Activity

  1. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    Correction: this is escape analysis, not realm population. Fix in #10705.

    The "Relationship to #10686" section above is wrong, and it was my analysis. Both repros in this issue use JSON.stringify(o), which does two things at once — it forces populate_global_this_builtins and it makes o escape. I attributed the repair to the first when it was the second.

    Separating the variables:

    const o = { a: 1 };
    JSON.stringify({ b: 2 });   // forces the realm; `o` does NOT escape
    typeof o.toString           // still undefined
    const o = { a: 1 };
    const s = []; s.push(o);    // `o` escapes; the realm is never forced
    typeof o.toString           // function — correct

    for...of forces the realm and does not fix it either. Escaping is what matters; realm population is incidental and my ordering observations were a coincidence of the repro.

    The real mechanism: check_escapes_in_expr's Expr::PropertyGet arm classified every read on a scalar-replacement candidate as a plain field read without checking that the class chain declares the key. The local stayed scalar-replaced — no heap object exists — and the scalar arm of expr/property_get.rs, finding no slot, folds the read to the constant undefined. Calls were always correct because a fused method call never consults the elided object.

    The same rule was already present three times on the write arms (#9024, #9460) and in escape_objects.rs. Only the read arm lacked it.

    The consequence for #10686: the "opposite directions" warning I put on both issues does not apply. They share no mechanism. This fix never needed anything to start forcing, so a non-forcing identity probe cannot weaken it — #10686 is unblocked rather than in tension, and it is now worth more, not less, because the programs this fix corrects newly pay its constant (the PR has the numbers, with a control showing a program that already forced the realm pays 0.04%).

    Blast radius was also wider than this issue states: a class's own prototype method read as a value, and user-added Object.prototype members.

  2. added 2 commits that reference this issue on Sep 19, 2026
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