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.
Summary
Reading an inherited
Object.prototypemember as a value from a plain object returnsundefineduntil 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.But every call through the same chain is correct:
And the prototype link itself is intact:
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
Anything that forces
populate_global_this_builtinsrepairs it for the rest of the process.JSON.stringifydoes. Merely evaluatingObjectorObject.prototypedoes not: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.stringifyanywhere gets another.Class instances and arrays are unaffected —
new C().constructor === Cand[].constructor === Arrayare both correct.Why this matters more than a typical divergence
Value-reads of
constructorandtoStringare 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 getundefined; 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.Object.prototype, so the answer isfalseeither way.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.