Repository navigation
json tape + gen-GC: element-wise materialization loses object key pointers, stringify emits field0/field1 (silent wrong JSON) #7538
Description
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
- json tape: full-array scans pay 2.3× the direct parser (field_access 2981ms vs bun 223) — decomposition + roadmap #7478 (tape scan performance; the decomposition that surfaced this)
- perf(json): flip lazy-tape scans to the batch parser early (#7478) #7537 (early batch-flip; reduces exposure, does not fix)
- gc: PERRY_GC_VERIFY_EVACUATION=1 aborts on PERRY_JSON_TAPE=1 — lazy-array sparse element cache reports 8205 missing old→young edges #7500 (
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.
Activity
proggeramlug commentedon Aug 6, 2026
Confirmed still reproducing on current
main(v0.5.1302, post-#7537) — correcting the "0/53 on the merged branch" reading in the PR report.Deterministic at 1/53, not intermittent. #7537 changed when materialization happens (scans now flip to the batch producer early), which plausibly moved the window rather than closing it — the reproducer above takes the compare-against-blob variant, and it still loses keys.
Raising priority: this is silent data corruption on the DEFAULT configuration. No throw, no diagnostic, no crash —
JSON.stringifyjust emits{"field0":…,"field1":…}where the source said{"x":…,"y":…}. Values and order are right; only key names are lost. That is worse than a segfault, because a user has no signal at all and would ship the wrong output.It needs both switches (tape + gen-GC), which is why the repo's own
bench_field_access.tsdoes not surface it and why it has survived: the two-switchidiomaticrow in the public baseline turns both off together, so no configuration in routine use exercises the failing combination while checking output.Prime suspicion given today's pattern (~20 rooting bugs of the same family): the object's
keys_arrayedge is being lost across a collection during element-wise materialization — either an unrooted local, or a missing write barrier when the materialized object's keys pointer is published into an older-generation object.JSON.stringify's positional fallback firing is exactly what a null/stalekeys_arraylooks like downstream.perf(gc): collapse adjacent duplicate external sticky entries (#7538)
fix(gc): root the overflow store's value across the spill allocations (…
perf(gc): collapse adjacent duplicate external sticky entries (#7538)
fix(gc): root the overflow store's value across the spill allocations (…
perf(gc): move the JSON tape out of the old generation (#7539) (#7553)