JSON.parse of an object with more than 10,000 keys produces an object whose values can't be read. Object.keys, in, hasOwnProperty and literal-key reads (v["k5"]) work. Computed-key reads (v["k" + i]) return undefined, and Object.entries(v) gives null for every value. Objects built by assignment (o["k" + i] = i) are unaffected.
Found on main (9b911855f8) during a merge-train audit; it is unrelated to the PR under audit (#10168), which behaves identically.
Reproduction
function viaJson(n: number) {
const p: string[] = [];
for (let i = 0; i < n; i++) p.push('"k' + i + '":' + i);
return JSON.parse("{" + p.join(",") + "}");
}
for (const n of [10000, 10001]) {
const v = viaJson(n);
const key = "k" + 5;
console.log(n, v[key], v["k5"], JSON.stringify(Object.entries(v)[5]));
}
Node 26.5.1:
10000 5 5 ["k5",5]
10001 5 5 ["k5",5]
Perry main (--no-auto-optimize, macOS arm64):
10000 5 5 ["k5",5]
10001 undefined 5 ["k5",null]
Bisected in-process: 10,000 keys are correct, 10,001 keys are broken (127,796 bytes of source). Values are irrelevant: the same happens with nested record values, e.g. a 60,000-key object of {"id","name","tags","score","active"} records reads v["k" + r] as undefined.
Likely cause
crates/perry-runtime/src/object/field_get_set/accessors.rs, object_field_at_with_live:
// Guard: corrupted objects with unreasonably large field_count
if live > 10000 {
return JSValue::undefined();
}
The JSON object builder keeps every field inline, so a parsed object with more than 10,000 fields has live > 10000. Every indexed read of it then returns undefined, whatever the field index. Assignment-built objects spill fields past their inline capacity into the overflow map (the field_index >= live branch above the guard), so they never reach it. Literal-key reads take a different lookup path, which is why v["k5"] still works.
The guard dates from a corruption-detection era. Either it should bound only the index actually being read against the object's allocated slot capacity, or the parse builders should respect the same inline cap that assignment does.
JSON.parseof an object with more than 10,000 keys produces an object whose values can't be read.Object.keys,in,hasOwnPropertyand literal-key reads (v["k5"]) work. Computed-key reads (v["k" + i]) returnundefined, andObject.entries(v)givesnullfor every value. Objects built by assignment (o["k" + i] = i) are unaffected.Found on
main(9b911855f8) during a merge-train audit; it is unrelated to the PR under audit (#10168), which behaves identically.Reproduction
Node 26.5.1:
Perry
main(--no-auto-optimize, macOS arm64):Bisected in-process: 10,000 keys are correct, 10,001 keys are broken (127,796 bytes of source). Values are irrelevant: the same happens with nested record values, e.g. a 60,000-key object of
{"id","name","tags","score","active"}records readsv["k" + r]asundefined.Likely cause
crates/perry-runtime/src/object/field_get_set/accessors.rs,object_field_at_with_live:The JSON object builder keeps every field inline, so a parsed object with more than 10,000 fields has
live > 10000. Every indexed read of it then returnsundefined, whatever the field index. Assignment-built objects spill fields past their inline capacity into the overflow map (thefield_index >= livebranch above the guard), so they never reach it. Literal-key reads take a different lookup path, which is whyv["k5"]still works.The guard dates from a corruption-detection era. Either it should bound only the index actually being read against the object's allocated slot capacity, or the parse builders should respect the same inline cap that assignment does.