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.
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-prettyJSON.stringify(value)on the same array is correct and stable.Reproducer
plainis 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:
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_depthdocuments it and guards against it:and calls
crate::array::clean_arr_ptrto follow the chain. The pretty/replacer emitters injson/replacer.rsnever got that guard. Unguardedas *const crate::ArrayHeadercasts followed by a(*arr).lengthread:stringify_array_with_replacer_prettystringify_value_prettystringify_array_prettystringify_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
lengthalso 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, andreplacer.rsdoes 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 noJSON.parseand no property read.