Repository navigation
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
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: functionBut 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 functionThat 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.
- perf: a ~25M-instruction one-time cost is triggered by for...of, JSON.stringify on objects, and array-like access (but not by spread, Promise, or Object.keys) #10686 is that an intrinsic-identity probe forces population it does not need, costing ~25M instructions. The fix there is to make that probe non-forcing — correct, because before population completes nothing can be
Object.prototype, so the answer isfalseeither way. - This issue is a value read that is non-forcing when it must force.
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
proggeramlug commentedon Sep 19, 2026
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 forcespopulate_global_this_builtinsand it makesoescape. I attributed the repair to the first when it was the second.Separating the variables:
for...offorces 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'sExpr::PropertyGetarm 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 ofexpr/property_get.rs, finding no slot, folds the read to the constantundefined. 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.prototypemembers.fix(codegen): an inherited property read no longer folds to undefined…
test(gap): lock commander's outputError/writeErr indirection (#10711)
fix(#10822): delete on a scalar-replaced receiver escapes it