Skip to content

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

Closed

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

Activity

  1. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Confirmed still reproducing on current main (v0.5.1302, post-#7537) — correcting the "0/53 on the merged branch" reading in the PR report.

    perry-dev build at 83dac78f0 + version bump, three consecutive runs:
      run 1: corrupted iterations:1
      run 2: corrupted iterations:1
      run 3: corrupted iterations:1
      node:  corrupted iterations:0
    

    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.stringify just 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.ts does not surface it and why it has survived: the two-switch idiomatic row 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_array edge 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/stale keys_array looks like downstream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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