Skip to content

json tape + gen-GC: element-wise materialization loses object key pointers, stringify emits field0/field1 (silent wrong JSON) #7538

Description

@proggeramlug

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions