Skip to content

fix(json): resolve GC_FLAG_FORWARDED array stubs in pretty/replacer stringify - #7713

Merged
proggeramlug merged 2 commits into
mainfrom
gc/7269-json-replacer-clean-ptr
Aug 9, 2026
Merged

fix(json): resolve GC_FLAG_FORWARDED array stubs in pretty/replacer stringify#7713
proggeramlug merged 2 commits into
mainfrom
gc/7269-json-replacer-clean-ptr

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes JSON.stringify(v, null, 2) returns nondeterministic garbage for arrays grown past inline capacity — pretty/replacer emitters miss clean_arr_ptr #7269: JSON.stringify(v, null, 2) on an array grown past its initial inline capacity (MIN_ARRAY_CAPACITY, 16) returned nondeterministic garbage — a different, wrong-length string on every run of the same binary, occasionally crashing.
  • Root cause: js_array_grow (Array.push from inside an async function silently caps at 16 elements when the array is a function parameter #233) reallocates and leaves a GC_FLAG_FORWARDED stub at the OLD address. The stub's first 8 bytes are exactly ArrayHeader.length + .capacity, now holding the raw forwarding pointer to the new array. Several call sites in crates/perry-runtime/src/json/replacer.rs cast an array pointer straight to *ArrayHeader without following that chain via crate::array::clean_arr_ptr — the plain (non-pretty) stringify path (json/stringify.rs::stringify_array_depth) already did this correctly.
  • Fixed sites (all in crates/perry-runtime/src/json/replacer.rs):
    • stringify_value_pretty's TYPE_UNKNOWN structural-fallback probe — the site actually hit by the issue's exact repro (JSON.stringify(v, null, 2), no replacer). This is the necessary fix; reverting only this one is enough to make the new test fail.
    • stringify_array_pretty — hardened as the shared choke point for both its callers, so it no longer depends on a caller having resolved first.
    • stringify_array_with_replacer_pretty — was safe today only because its one current caller happens to resolve first; fixed so it doesn't rely on that invariant.
    • extract_string_array and is_array_value — the replacer array argument (JSON.stringify(v, ['a','b'])) has the identical hazard on its own pointer; fixed while in the area.
    • One site the issue also cited, stringify_array_with_array_replacer, already resolved via clean_arr_ptr before this issue was filed and needed no change — noted in the changelog fragment.

Test plan

  • New test pretty_stringify_resolves_array_grown_past_inline_capacity (crates/perry-runtime/src/json/replacer.rs): grows a real array past its allocated capacity (no GC cycle needed — js_array_grow installs the forwarding stub unconditionally on every reallocating grow), asserts the stale header's raw (length, capacity) bytes reconstruct the grown array's exact address (sabotage precondition), then pretty-stringifies the stale pre-grow pointer and asserts the output matches the array's real, current contents.
  • Verified the test fails without the fix: reverting the stringify_value_pretty resolve alone (keeping the stringify_array_pretty defense-in-depth resolve) still made the test process abort (SIGABRT, "thread panicked while processing panic") — the misread length (up to ~4 billion) drove the pretty-printer into a deep, self-feeding recursion over adjacent heap bytes reinterpreted as more NaN-boxed values, overflowing the stack. Restored the fix afterward and confirmed a clean pass.
  • cargo test -p perry-runtime --lib --no-fail-fast: 1957 passed, 0 failed, 4 ignored.
  • cargo fmt --all -- --check: clean.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed pretty-printed JSON serialization for arrays that grow beyond their original capacity.
    • Ensured array contents and replacer-based serialization remain correct when arrays move in memory.
    • Added handling for unavailable array references, producing null or an empty key list as appropriate.
  • Tests

    • Added regression coverage for serialization through stale array references and verification of current array contents.

…tringify

JSON.stringify(v, null, 2) on an array grown past its initial inline
capacity returned nondeterministic garbage (occasionally crashing) because
several call sites in json/replacer.rs cast an array pointer straight to
*ArrayHeader without following the js_array_grow (#233) forwarding chain
that clean_arr_ptr resolves. The plain (non-pretty) stringify path already
did this correctly; the pretty-print/replacer paths did not.

Fixes #7269.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Pretty JSON array paths now resolve forwarding pointers created by array growth. The change covers replacers, fallback serialization, PropertyList extraction, and array detection. A regression test validates stale-pointer serialization. Project version metadata changes to 0.5.1414.

Changes

JSON array pointer cleanup

Layer / File(s) Summary
Resolve forwarded pointers
crates/perry-runtime/src/json/replacer.rs
Array serialization, replacer handling, PropertyList extraction, and array detection call clean_arr_ptr before reading array headers. Unresolved pointers produce null, an empty key list, or false.
Validate stale-pointer serialization
crates/perry-runtime/src/json/replacer.rs, changelog.d/7713-json-replacer-clean-ptr.md
A regression test covers pretty serialization through a stale pointer after array growth. The changelog documents the affected paths and coverage.
Update version metadata
Cargo.toml, CLAUDE.md
The workspace package version and documented current version change from 0.5.1413 to 0.5.1414.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • PerryTS/perry issue 7269 — Describes the same JSON replacer array pointer-cleaning bug.

Possibly related PRs

  • PerryTS/perry#7265 — Updates JSON array serialization for arrays that exceed inline storage capacity.
  • PerryTS/perry#7551 — Resolves grown-array forwarding pointers in another runtime subsystem.
  • PerryTS/perry#7603 — Uses clean_arr_ptr for array-pointer normalization in related runtime validation.

Suggested labels: bug

Suggested reviewers: jdalton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the JSON stringify bug and the forwarding-stub resolution.
Description check ✅ Passed The description clearly explains the issue, root cause, fixes, regression test, and verification results.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7269-json-replacer-clean-ptr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/json/replacer.rs`:
- Around line 894-899: After clean_arr_ptr resolves successfully, stop using the
original ptr and consistently use resolved for the object-pointer check,
stringify_array_pretty call, and all fallback length/capacity accesses. Ensure
every dereference after clean_arr_ptr is based on the resolved pointer so
materialization cannot leave the code inspecting an invalid raw pointer.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f03bccf-8628-4efa-a104-7b7419c6a19a

📥 Commits

Reviewing files that changed from the base of the PR and between 0328a63 and 9d28a8f.

📒 Files selected for processing (2)
  • changelog.d/7713-json-replacer-clean-ptr.md
  • crates/perry-runtime/src/json/replacer.rs

Comment on lines +894 to +899
let resolved = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader);
if !resolved.is_null() {
let len = (*resolved).length;
let cap = (*resolved).capacity;
if len <= cap && cap > 0 && cap < 10000 && !is_object_pointer(ptr) {
stringify_array_pretty(ptr, buf, indent, depth);
stringify_array_pretty(resolved as *const u8, buf, indent, depth);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use the resolved pointer after clean_arr_ptr.

clean_arr_ptr can materialize a lazy array and allocate. It returns the current array pointer. Lines 898 and 903-907 still inspect the original ptr. A collection during materialization can invalidate that raw pointer and cause an invalid dereference.

Use resolved as *const u8 for the object probe, serialization call, and fallback accesses when resolution succeeds.

Proposed fix
 let resolved = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader);
+let current_ptr = if resolved.is_null() {
+    ptr
+} else {
+    resolved as *const u8
+};
 if !resolved.is_null() {
     let len = (*resolved).length;
     let cap = (*resolved).capacity;
-    if len <= cap && cap > 0 && cap < 10000 && !is_object_pointer(ptr) {
-        stringify_array_pretty(resolved as *const u8, buf, indent, depth);
+    if len <= cap && cap > 0 && cap < 10000 && !is_object_pointer(current_ptr) {
+        stringify_array_pretty(current_ptr, buf, indent, depth);
         return;
     }
 }
-if is_object_pointer(ptr) {
-    stringify_object_pretty(ptr, buf, indent, depth);
+if is_object_pointer(current_ptr) {
+    stringify_object_pretty(current_ptr, buf, indent, depth);
 } else {
-    let str_ptr = ptr as *const StringHeader;
+    let str_ptr = current_ptr as *const StringHeader;

Based on learnings: raw Rust pointer locals are neither GC roots nor reliable pins after an allocating operation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let resolved = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader);
if !resolved.is_null() {
let len = (*resolved).length;
let cap = (*resolved).capacity;
if len <= cap && cap > 0 && cap < 10000 && !is_object_pointer(ptr) {
stringify_array_pretty(ptr, buf, indent, depth);
stringify_array_pretty(resolved as *const u8, buf, indent, depth);
let resolved = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader);
let current_ptr = if resolved.is_null() {
ptr
} else {
resolved as *const u8
};
if !resolved.is_null() {
let len = (*resolved).length;
let cap = (*resolved).capacity;
if len <= cap && cap > 0 && cap < 10000 && !is_object_pointer(current_ptr) {
stringify_array_pretty(current_ptr, buf, indent, depth);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/json/replacer.rs` around lines 894 - 899, After
clean_arr_ptr resolves successfully, stop using the original ptr and
consistently use resolved for the object-pointer check, stringify_array_pretty
call, and all fallback length/capacity accesses. Ensure every dereference after
clean_arr_ptr is based on the resolved pointer so materialization cannot leave
the code inspecting an invalid raw pointer.

Source: Learnings

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Cargo.toml`:
- Line 318: Revert the contributor-added release metadata changes at Cargo.toml
lines 318-318 and CLAUDE.md lines 11-11: restore the workspace version and
documented current version to their pre-change values, leaving maintainer-owned
version updates for the PR merge/release workflow.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 753701df-f7b7-4c28-8c47-b38f2e1836f4

📥 Commits

Reviewing files that changed from the base of the PR and between 9d28a8f and 4093d95.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml

Comment thread Cargo.toml

[workspace.package]
version = "0.5.1413"
version = "0.5.1414"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

for file in CONTRIBUTING.md CLAUDE.md Cargo.toml; do
  if [ -f "$file" ]; then
    rg -n -C 3 'workspace\.package|Current Version|maintainer|release|version' "$file"
  fi
done

Repository: PerryTS/perry

Length of output: 22003


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- README version/bump related sections ---\n'
sed -n '1,95p' README.md

printf '\n--- CLAUDE first 15 lines ---\n'
sed -n '1,15p' CLAUDE.md

printf '\n--- Cargo workspace package section ---\n'
sed -n '316,319p' Cargo.toml

printf '\n--- Git status/diff summary ---\n'
git status --short
git diff --stat
git diff -- Cargo.toml CLAUDE.md README.md

Repository: PerryTS/perry

Length of output: 242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- README version/bump related sections ---'
sed -n '1,95p' README.md

printf '%s\n' ''
printf '%s\n' '--- CLAUDE first 15 lines ---'
sed -n '1,15p' CLAUDE.md

printf '%s\n' ''
printf '%s\n' '--- Cargo workspace package section ---'
sed -n '316,319p' Cargo.toml

printf '%s\n' ''
printf '%s\n' '--- Git status/diff summary ---'
git status --short
git diff --stat
git diff -- Cargo.toml CLAUDE.md README.md || true

Repository: PerryTS/perry

Length of output: 7981


Align the contributed release metadata with Perry’s PR workflow.

README.md says maintainers bump [workspace.package].version in Cargo.toml and **Current Version:** in CLAUDE.md at merge/release. Revert these contributor-added metadata edits if this is an external contributor PR.

  • Cargo.toml#L318-L318: Revert the workspace version if maintainers own version updates.
  • and
  • CLAUDE.md#L11-L11: Revert the documented version if maintainers own release metadata.
📍 Affects 2 files
  • Cargo.toml#L318-L318 (this comment)
  • CLAUDE.md#L11-L11
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Cargo.toml` at line 318, Revert the contributor-added release metadata
changes at Cargo.toml lines 318-318 and CLAUDE.md lines 11-11: restore the
workspace version and documented current version to their pre-change values,
leaving maintainer-owned version updates for the PR merge/release workflow.

Sources: Coding guidelines, Learnings

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1414

Good result, and the interesting parts are the two places it departs from the issue.

The issue's citation list was partly wrong, and the PR says so rather than fixing four things to match it. Of the four sites cited (at drifted line numbers):

  • stringify_value_pretty's TYPE_UNKNOWN heuristic — genuinely broken, and the one the issue's own repro (JSON.stringify(v, null, 2), no replacer) actually hits. Read (*arr).length/.capacity raw before resolving forwarding.
  • stringify_array_prettygenuinely broken, same raw cast.
  • stringify_array_with_replacer_pretty — an unresolved cast in its own body, but currently shielded by its only caller resolving first. Fixed anyway, so correctness stops depending on a caller's behaviour.
  • stringify_array_with_array_replaceralready correct before the issue was filed. Left alone.

And tracing the call graph turned up two more of the same bug class on the replacer-array argument (JSON.stringify(v, ['a','b'])): extract_string_array and is_array_value.

The test is deterministic, which is better than the issue's own repro. "Nondeterministic garbage" invites a test that passes by luck. Instead it grows a real array past capacity and relies on js_array_grow installing the GC_FLAG_FORWARDED stub unconditionally on every reallocating grow (array/push_pop.rs:118, issue #233) — so no GC cycle has to land for the stale pointer to exist. I verified that claim directly, along with clean_arr_ptr's forwarding follow (array/header.rs:511), because the whole test rests on it.

It then asserts the stale header's raw (length, capacity) bytes reconstruct the grown array's address — pinning that the reader really is misreading a forwarding stub, not just reading something odd.

The sabotage is the strongest part. Reverting only the stringify_value_pretty fix did not produce a failed assertion — the process aborted (SIGABRT, "thread panicked while processing panic"), because the misread multi-billion length drove runaway recursion into a stack overflow. That is the actual severity of this bug, and it is worth more than the assertion would have been.

cargo test -p perry-runtime --lib --no-fail-fast: 1957 passed, 0 failed. Lint 19/19.

@proggeramlug
proggeramlug merged commit e732f82 into main Aug 9, 2026
13 of 15 checks passed
@proggeramlug
proggeramlug deleted the gc/7269-json-replacer-clean-ptr branch August 9, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant