Summary
With the lazy JSON tape enabled and the generational GC enabled, a full element-wise scan of a parsed top-level array can produce objects whose key pointers are lost. JSON.stringify then emits the positional fallback names — {"field0":8184,"field1":16368} where the source said {"x":8184,"y":16368}.
Silent data corruption on the default configuration: no throw, no diagnostic, just wrong JSON out. The values are correct and in the right order; only the key names are gone.
Found while decomposing #7478. Reproduces on origin/main @ fc5e56e.
Reproducer
Parse, touch every element, stringify, compare against the blob it came from:
const items: any[] = [];
for (let i = 0; i < 10000; i++) {
items.push({
id: i, name: "item_" + i, value: i * 3.14159,
tags: ["tag_" + (i % 10), "tag_" + (i % 5)],
nested: { x: i, y: i * 2 }
});
}
const blob = JSON.stringify(items);
let bad = 0;
for (let iter = 0; iter < 53; iter++) {
const parsed = JSON.parse(blob);
let sum = 0;
for (let i = 0; i < parsed.length; i++) sum += parsed[i].nested.x;
const re = JSON.stringify(parsed);
if (re !== blob) {
bad++;
let lo = 0;
while (lo < blob.length && blob[lo] === re[lo]) lo++;
console.log("iter:" + iter + " delta:" + (re.length - blob.length) + " at:" + lo);
console.log(" blob:" + blob.slice(lo - 50, lo + 50));
console.log(" re :" + re.slice(lo - 50, lo + 50));
}
}
console.log("bad-iterations:" + bad + "/53");
Output on main, default flags:
iter:2 delta:10 at:868696
blob:.772559999998,"tags":["tag_4","tag_4"],"nested":{"x":8184,"y":16368}},{"id":8185,...
re :.772559999998,"tags":["tag_4","tag_4"],"nested":{"field0":8184,"field1":16368}},...
bad-iterations:1/53
Reproduces 1 of 53 iterations on every run (5/5 runs), but which iteration and which record varies between builds — an earlier build hit iteration 9 / record 8740, this one iteration 2 / record 8184. That variability is the tell that it is collection-timing dependent rather than a fixed data case.
Scope — read this before trying to reproduce
benchmarks/json_polyglot/bench_field_access.ts does NOT reproduce it. That benchmark is the same workload shape, and its checksum matches node exactly (2552985550, 6/6 runs on main). The reproducer above differs from it only in that it compares against the blob every iteration instead of accumulating a checksum — enough of an allocation/GC timing shift to expose the defect. So this is not "the benchmark is broken"; it is a latent defect that the benchmark's particular timing happens to miss.
What is known
| condition |
result |
| default (tape + gen-GC) |
1/53 iterations corrupted |
PERRY_GEN_GC=0 (tape still on) |
clean |
PERRY_JSON_TAPE=0 |
clean |
| touching only a 100-element prefix of the same array |
clean, 0/400 iterations |
So it needs the tape and the generational collector and enough element-wise materialization — a full 10,000-element scan, i.e. ~10,000 lazy_get cold-path materializations per iteration.
field0/field1 is json/stringify.rs's fallback when str_from_header(key_ptr) returns None, so the key header is null or unreadable by the time stringify walks the object.
Where to look
json_tape.rs::materialize_object builds each object with js_object_alloc(0, 0) and then js_object_set_field_by_name per field, with the key coming from decode_key_to_interned_string — which returns a raw *mut StringHeader read out of the PARSE_KEY_CACHE side table:
let cached = crate::json::PARSE_KEY_CACHE.with(|c| c.borrow().get(slice).copied());
if let Some(p) = cached {
return p as *mut crate::StringHeader;
}
That is the "runtime-side cache of a raw heap pointer" shape CLAUDE.md calls out as structurally invisible to scripts/gc_root_dominance_check.py. Note the usual tell does not match: that heuristic says a bad table fails from collection #0 and reproducibly, whereas this is intermittent and timing-dependent — so an unrooted table is probably not the whole story on its own.
The object's own shape-keys array is the other candidate. materialize_object grows the object field-by-field through the transition cache, where DirectParser::parse_object_untyped pre-sizes from a known field count with a keys array built behind an inline hot-shape cache. A keys array allocated during a growth transition, dropped by an evacuating minor landing inside js_object_set_field_by_name, would present exactly like this.
Worth checking whether the interning path the tape materializer uses is covered by gc_register_mutable_root_scanner.
Impact and mitigation
Anything that parses a ≥1 KB top-level JSON array (auto mode's tape window) and iterates all of it can silently emit wrong JSON.
PERRY_JSON_TAPE=0 is a complete workaround.
#7537 removes the exposure for scan-shaped access — it flips to the batch parser after ~1.5% of the array, so the element-wise path stops running for that shape (the reproducer above goes to 0/53 across 3 runs on that branch). It does not fix the defect: any workload that still materializes many elements one at a time remains exposed.
Related
Summary
With the lazy JSON tape enabled and the generational GC enabled, a full element-wise scan of a parsed top-level array can produce objects whose key pointers are lost.
JSON.stringifythen emits the positional fallback names —{"field0":8184,"field1":16368}where the source said{"x":8184,"y":16368}.Silent data corruption on the default configuration: no throw, no diagnostic, just wrong JSON out. The values are correct and in the right order; only the key names are gone.
Found while decomposing #7478. Reproduces on
origin/main@ fc5e56e.Reproducer
Parse, touch every element, stringify, compare against the blob it came from:
Output on
main, default flags:Reproduces 1 of 53 iterations on every run (5/5 runs), but which iteration and which record varies between builds — an earlier build hit iteration 9 / record 8740, this one iteration 2 / record 8184. That variability is the tell that it is collection-timing dependent rather than a fixed data case.
Scope — read this before trying to reproduce
benchmarks/json_polyglot/bench_field_access.tsdoes NOT reproduce it. That benchmark is the same workload shape, and its checksum matches node exactly (2552985550, 6/6 runs onmain). The reproducer above differs from it only in that it compares against the blob every iteration instead of accumulating a checksum — enough of an allocation/GC timing shift to expose the defect. So this is not "the benchmark is broken"; it is a latent defect that the benchmark's particular timing happens to miss.What is known
PERRY_GEN_GC=0(tape still on)PERRY_JSON_TAPE=0So it needs the tape and the generational collector and enough element-wise materialization — a full 10,000-element scan, i.e. ~10,000
lazy_getcold-path materializations per iteration.field0/field1isjson/stringify.rs's fallback whenstr_from_header(key_ptr)returnsNone, so the key header is null or unreadable by the time stringify walks the object.Where to look
json_tape.rs::materialize_objectbuilds each object withjs_object_alloc(0, 0)and thenjs_object_set_field_by_nameper field, with the key coming fromdecode_key_to_interned_string— which returns a raw*mut StringHeaderread out of thePARSE_KEY_CACHEside table:That is the "runtime-side cache of a raw heap pointer" shape CLAUDE.md calls out as structurally invisible to
scripts/gc_root_dominance_check.py. Note the usual tell does not match: that heuristic says a bad table fails from collection #0 and reproducibly, whereas this is intermittent and timing-dependent — so an unrooted table is probably not the whole story on its own.The object's own shape-keys array is the other candidate.
materialize_objectgrows the object field-by-field through the transition cache, whereDirectParser::parse_object_untypedpre-sizes from a known field count with a keys array built behind an inline hot-shape cache. A keys array allocated during a growth transition, dropped by an evacuating minor landing insidejs_object_set_field_by_name, would present exactly like this.Worth checking whether the interning path the tape materializer uses is covered by
gc_register_mutable_root_scanner.Impact and mitigation
Anything that parses a ≥1 KB top-level JSON array (auto mode's tape window) and iterates all of it can silently emit wrong JSON.
PERRY_JSON_TAPE=0is a complete workaround.#7537 removes the exposure for scan-shaped access — it flips to the batch parser after ~1.5% of the array, so the element-wise path stops running for that shape (the reproducer above goes to 0/53 across 3 runs on that branch). It does not fix the defect: any workload that still materializes many elements one at a time remains exposed.
Related
PERRY_GC_VERIFY_EVACUATION=1aborts on anyPERRY_JSON_TAPE=1workload — lazy sparse-cache slots report missing old→young edges). Plausibly the same root cause; worth triaging together.