-
-
Notifications
You must be signed in to change notification settings - Fork 155
fix(json): stringify dropped every array element's properties past the inline slot floor #7265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| fix(json): `JSON.stringify` silently dropped every array element's properties past the inline-slot floor (#7264). | ||
|
|
||
| `JSON.stringify(arr)` truncated **every** element of a homogeneous array of objects to 4 properties — no error, no warning, just short output — as soon as the array came from `JSON.parse` and any property of any element had been read. `JSON.stringify(JSON.parse(x))` is a routine idiom, so anything that parses JSON, reads a field, and re-serialises (a proxy, a cache layer, a request handler) could emit truncated records. | ||
|
|
||
| **Root cause.** The array fast path builds one shape template from element 0 and reuses its pre-formatted key prefixes for every element. It sized that template from `min(keys_len, field_count)`. `keys_len` is the *logical* property count; `field_count` is *physical* — it never exceeds the object's inline slot allocation. An object grown by name past `INLINE_SLOT_FLOOR` keeps `field_count` pinned at the floor and parks the remaining values in overflow storage. `JSON.parse`'s lazy-tape materializer builds exactly that shape (`js_object_alloc(0, 0)` plus one `js_object_set_field_by_name` per key), so a 6-key record reports `field_count == 4` and the template emitted — and read — only 4 slots. | ||
|
|
||
| Only the *array* path was wrong, which is what made this so hard to see: the single-object path already knew the invariant (its `has_overflow_fields` guard bails out of the template when `keys_len > field_count`), so `JSON.stringify(parsed[0])`, `parsed.map(o => JSON.stringify(o))` and `Object.keys(parsed[0])` all reported the truth while the whole-array result did not. | ||
|
|
||
| `build_shape_prefix_template` now sizes the template from `keys_len` and routes slot reads through `template_field_bits` / `field_bits_at`, which read inline below `max(field_count, INLINE_SLOT_FLOOR)` and fall through to `js_object_get_field`'s overflow lookup above it — the same rule `stringify_object_inner` has used since #307. The `min` was never needed for the opposite skew either: a pre-sized object (`js_object_alloc(0, 8)` holding 2 keys) has `field_count > keys_len`, and `keys_len` already stops at the last real key. | ||
|
|
||
| Latent since the shape template landed (v0.5.65), where it only bit objects with 9+ properties; exposed for ordinary 5–8-field records by 6958a5a8d (#6712), which lowered `INLINE_SLOT_FLOOR` from 8 to 4. That is why the `2026-07-13` public baseline passed the same checksum gate this failed. | ||
|
|
||
| **Second defect, same emitter.** The template's primitive-only fast path is chosen by *sampling element 0*, then trusted every later element to be primitive too. A function- or symbol-valued property in a later element must be omitted per `SerializeJSONObject`, but the fast path had already written the key prefix and rendered the closure as `null` — emitting a member that must not exist (`{"f2":null}` where node emits nothing). Only the general path pre-scanned for it. It now rolls the buffer back and defers to the slow path, exactly as it already did for a stray `undefined`. Applies to inline slots as much as overflow ones. | ||
|
|
||
| **Harness.** `benchmarks/json_polyglot/run.sh` printed `Machine-readable results: <path>` and exited 0 while its embedded publisher had already aborted via `raise SystemExit(...)` and written nothing — the script runs under `set -uo pipefail`, not `-e`. `run_public_baseline.sh` therefore sailed past it and failed ~40 minutes later with a misleading "could not load json-polyglot.json". Two changes: a cross-runtime checksum gate now runs unconditionally right after the benchmark cells, names the disagreeing runtimes, and fails the leg immediately; and the publisher's exit status plus the existence of a non-empty output file are both checked before the success line prints. A stale artifact from a previous run is also removed up front so it can't be mistaken for this run's output. | ||
|
|
||
| Verified: `benchmarks/json_polyglot/bench_field_access.ts` now checksums `2552985550` and `bench.ts` `53735550` under perry, node 26.5.1 and bun 1.3.14 alike (perry previously gave `2538318800` on the first), unblocking the public-baseline regeneration in #7257. Field counts 1–12 round-trip byte-identically; the untouched-array, one-read and loop-read forms all agree; the nested-subtree shape from the report is byte-identical. New `--lib` unit tests in `crates/perry-runtime/src/json/mod.rs` (which run per-PR, unlike `crates/*/tests/*.rs`) plus `test-files/test_gap_json_array_element_overflow_fields.ts`. Full 471-test gap suite and `cargo test -p perry-runtime --lib` show no regressions. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 151
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 18886
Require perry, node, and bun checksum rows before accepting agreement.
The checksum gate currently only checks checksum consistency across any present rows. If a workload has only one perry/node/bun checksum row,
distinctis 1 and the gate passes, even though the gate states Perry, node, and bun must agree exactly. Requireruntime_count == 3before accepting a single checksum value.🤖 Prompt for AI Agents