Skip to content

JSON.stringify(v, null, 2) returns nondeterministic garbage for arrays grown past inline capacity — pretty/replacer emitters miss clean_arr_ptr #7269

Description

@proggeramlug

Found while verifying #7264 (PR #7265). Unrelated to that bug, and worse: the output is nondeterministic garbage, not merely wrong.

JSON.stringify(value, null, 2) on an array that has grown past its initial inline capacity returns a string of a different, wrong length on every run of the same binary. The non-pretty JSON.stringify(value) on the same array is correct and stable.

Reproducer

function build(): any[] {
  const items: any[] = [];
  for (let i = 0; i < 32; i++) items.push({ f0: i, f1: i + 1, f2: i + 2 });
  return items;
}
const a = build();
console.log("pretty " + JSON.stringify(a, null, 2).length + " plain " + JSON.stringify(a).length);
node:   pretty 1575  plain 806
perry:  pretty 2587  plain 806
perry:  pretty 6146  plain 806     <- same binary, second run
perry:  pretty 6533  plain 806     <- same binary, third run

plain is correct every time. Only the indented form is wrong, and it is wrong differently each run — it is reading uninitialized/foreign memory. Slicing the result (.slice(0, 200)) yields an empty string, and some variants of this shape SIGSEGV.

Threshold

It is the array's growth past its initial inline capacity (16), not the element count as such:

element count:   4     8     16    32
node   pretty:   190   378   775   1575
perry  pretty:   190   378   775   4097   <- and 6655 on the next run

Correct through 16, garbage at 32. Building the same array at top level instead of inside a function is correct and stable at 32 — consistent with the array being reallocated and the caller holding the pre-growth pointer.

Suspected cause

This is the #2021 forwarding-stub class. json/stringify.rs::stringify_array_depth documents it and guards against it:

an array that has grown past its initial inline capacity (16) was reallocated to a new block, leaving a GC_FLAG_FORWARDED stub at the old address … reading its first 8 bytes as (length, capacity) yields the forwarding pointer reinterpreted as a huge length and walks off into garbage

and calls crate::array::clean_arr_ptr to follow the chain. The pretty/replacer emitters in json/replacer.rs never got that guard. Unguarded as *const crate::ArrayHeader casts followed by a (*arr).length read:

line function
544 stringify_array_with_replacer_pretty
868 stringify_value_pretty
1040 stringify_array_pretty
1297 stringify_array_with_array_replacer

(Lines 296 and 1282 in the same file do call clean_arr_ptr, so the fix is a one-line-per-site addition matching the existing pattern, not new machinery.)

A huge bogus length also explains the SIGSEGVs: with a replacer or an indent argument, JSON.stringify(JSON.parse(blob), null, 2) over a 32-element array segfaults outright.

Scope

Reproduces on origin/main (d4ac65f21) and with auto-optimize both on and off. Not introduced by #7265 — that PR touches only the non-pretty array shape template, and replacer.rs does not use it. Verified by A/B against a build with #7265's runtime changes reverted: same garbage, same nondeterminism.

Impact: any code that pretty-prints JSON — config writers, log formatters, API responses built with JSON.stringify(x, null, 2) — silently emits corrupt output once the array crosses 16 elements. That is a wider blast radius than #7264, since it needs no JSON.parse and no property read.

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