Skip to content

fix(gc): JSON.parse read its input after the source string moved - #7373

Merged
proggeramlug merged 1 commit into
mainfrom
fix/7341-json-parse-stale-input
Aug 4, 2026
Merged

fix(gc): JSON.parse read its input after the source string moved#7373
proggeramlug merged 1 commit into
mainfrom
fix/7341-json-parse-stale-input

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes the largest cluster in #73418 of 31 quarantine catches.

The bug

Both js_json_parse and js_json_parse_result did this:

let bytes = slice::from_raw_parts(data_ptr, len);   // derived from the StringHeader
serde_json::from_slice::<IgnoredAny>(bytes)         // allocates
gc_check_trigger();                                 // deliberate collection point
gc_suppress();
let text_root = parse_root_push(text_ptr);          // root pushed AFTER the collection
DirectParser::new(bytes)                            // parses a stale slice

The gc_check_trigger() is intentional — it sheds parse-churn garbage between iterations — but it is a collection point, and both the slice and the root came after it.

Ordering is the entire fix

I tried re-deriving the slice after the suppression first, and it measured 0/8. Pushing the root after the collection roots an address the collector has already moved away from, so reading it back returns the same stale pointer. The root has to precede the trigger; then the re-read yields the post-move payload.

Two copies of the pattern exist. The backtrace names js_json_parse; I patched js_json_parse_result first, which is why the first attempt changed nothing. Both are fixed.

Verification

8/8 cluster tests go from 6/6 faults to 0/6. 7/8 byte-identical to Node — the 8th (test_gap_6375_legacy_url_parse_format) was already diff on main before this change. 55/55 JSON unit tests pass.

Invisible from output on purpose: evacuation copies rather than zeroes, so the stale address still held the right bytes and every test printed the correct answer. PERRY_GC_PROTECT_FROMSPACE=1 faults on the first peek().

Summary by CodeRabbit

  • Bug Fixes
    • Improved JSON parsing reliability during garbage collection.
    • Prevented parsing from using outdated string data when memory is reorganized.
    • Resolved several edge-case failures in JSON parsing tests.

Both js_json_parse and js_json_parse_result derived a byte slice from the
source StringHeader, then called gc_check_trigger() -- a deliberate
collection point, there to shed parse-churn garbage between iterations --
and only THEN pushed the string's GC root and suppressed collection. An
evacuating minor at that trigger relocates the string, and the parser
reads the pre-collection address for the whole parse.

Ordering is the entire fix. I tried re-deriving the slice after the
suppression first and it changed nothing, because the ROOT was also
pushed after the collection: rooting an address the collector has already
moved away from means reading it back returns the same stale pointer.
Rooting before the trigger is what makes the re-read work.

Two separate copies of this pattern existed; the backtrace named
js_json_parse and I patched js_json_parse_result first, which is why the
first attempt measured 0/8. Both are fixed.

Invisible from output -- evacuation copies rather than zeroes, so the
stale address still held the right bytes and every test printed the
correct answer. PERRY_GC_PROTECT_FROMSPACE faults on the first peek().

8/8 cluster tests now clean (6/6 faults before, 0/6 after), 7/8
byte-identical to Node with the 8th already differing on main, 55/55 json
unit tests pass. Closes the largest cluster in #7341.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The JSON parser now roots source strings before garbage collection and re-derives their byte slices afterward. The change applies to js_json_parse_result and js_json_parse, with a changelog entry documenting the stale-input bug.

Changes

JSON parser GC safety

Layer / File(s) Summary
Root and reload JSON input
crates/perry-runtime/src/json/parse_api.rs, changelog.d/7373-json-parse-stale-input.md
Both JSON parsing functions protect the source string before GC-triggering calls and rebuild the input byte slice from the relocated root before parsing. The changelog documents the fix and its test impact.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7227 — Uses the same root-before-collection and reload-after-relocation pattern.
  • PerryTS/perry#7342 — Applies the same moving-GC safety pattern to array stores.
  • PerryTS/perry#7240 — Fixes stale pointers across moving-GC operations in another runtime path.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the garbage-collection bug fixed in JSON.parse input handling.
Description check ✅ Passed The description covers the bug, fix, related issue, and verification results, providing the main information required by the template.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7341-json-parse-stale-input

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: 2

🤖 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/parse_api.rs`:
- Around line 289-305: Move creation of the source-string root before the first
collection point, including gc_collect_pending_suppressed_parse(), and carry
that rooted handle through the tape branch. After every possible collection,
re-derive text_ptr and bytes from the rooted value before parsing, restore/pop
the root on every return path, and add a from-space-quarantine test covering
pending collection with PERRY_JSON_TAPE=1.
- Around line 152-157: The bytes slice derived from text_root at lines 152-157
becomes invalid after the post-parse GC at lines 161-163 relocates the source
string, and similarly at lines 315-320 the parser state and bytes become stale
after GC at lines 329-331. At the anchor site (lines 152-157), move the
evaluation of any null or error input state that depends on bytes to after the
GC sequence completes, or re-derive the bytes from text_root after the GC before
they are used in null/error handling. At the sibling site (lines 315-320),
evaluate parser.has_trailing_content() before the GC sequence begins (before
lines 329-331), and re-derive or copy any bytes needed by null or error paths
after the GC but before text_root is restored. Ensure all GC-managed values
remain rooted and all string byte views are re-derived after moving GC rather
than held across the collection point.
🪄 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: 8281cfe4-d4bf-46e4-9e49-a7adc14740b2

📥 Commits

Reviewing files that changed from the base of the PR and between 148f97b and 15a972b.

📒 Files selected for processing (2)
  • changelog.d/7373-json-parse-stale-input.md
  • crates/perry-runtime/src/json/parse_api.rs

Comment on lines +152 to +157
let bytes = {
let moved = crate::json::parse_root_get(text_root);
let hdr = moved.as_string_ptr();
let data_ptr = (hdr as *const u8).add(std::mem::size_of::<StringHeader>());
std::slice::from_raw_parts(data_ptr, len)
};

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 | 🔴 Critical | ⚡ Quick win

Do not retain parser input across post-parse GC.

The post-parse GC sequence can relocate the source string. Updating text_root does not update bytes or DirectParser. Both functions restore text_root before later input reads.

  • crates/perry-runtime/src/json/parse_api.rs#L152-L157: Evaluate or copy all null/error input state while GC is suppressed, or re-derive it after Lines 161-163 and before restoring text_root.
  • crates/perry-runtime/src/json/parse_api.rs#L315-L320: Evaluate parser.has_trailing_content() before Lines 329-331. Re-derive or copy bytes needed by null/error handling before restoring text_root.

When parse-boundary collection runs, normal trailing-content checks and null/error paths can read retired from-space.

As per coding guidelines, GC-managed values must remain rooted across every possible collection point. Based on learnings, source-string byte views are invalid after moving GC until they are re-derived.

📍 Affects 1 file
  • crates/perry-runtime/src/json/parse_api.rs#L152-L157 (this comment)
  • crates/perry-runtime/src/json/parse_api.rs#L315-L320
🤖 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/parse_api.rs` around lines 152 - 157, The bytes
slice derived from text_root at lines 152-157 becomes invalid after the
post-parse GC at lines 161-163 relocates the source string, and similarly at
lines 315-320 the parser state and bytes become stale after GC at lines 329-331.
At the anchor site (lines 152-157), move the evaluation of any null or error
input state that depends on bytes to after the GC sequence completes, or
re-derive the bytes from text_root after the GC before they are used in
null/error handling. At the sibling site (lines 315-320), evaluate
parser.has_trailing_content() before the GC sequence begins (before lines
329-331), and re-derive or copy any bytes needed by null or error paths after
the GC but before text_root is restored. Ensure all GC-managed values remain
rooted and all string byte views are re-derived after moving GC rather than held
across the collection point.

Sources: Coding guidelines, Learnings

Comment on lines +289 to +305
// #7341: root the source string BEFORE `gc_check_trigger`, and re-derive
// the input slice from the rooted value afterwards.
//
// The `gc_check_trigger()` immediately below is deliberate — see the
// comment above, it is what keeps parse-churn garbage shedding between
// iterations — but it is a COLLECTION POINT, and both `bytes` and
// `text_ptr` were derived above it. An evacuating minor there moves the
// source string, and the parser then reads the pre-collection address for
// the entire parse; the from-space quarantine reports it as a fault at
// `parse_value + 36`, on the very first `peek()`.
//
// Pushing the root first is what makes the re-read work. The old order
// pushed it AFTER the trigger, which roots an address the collector has
// already moved away from — so re-deriving from that slot returns the same
// stale pointer and fixes nothing. Rooting first means the collector
// rewrites the slot, and the re-read yields the post-move payload.
let text_root = parse_root_push(JSValue::string_ptr(text_ptr as *mut StringHeader));

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 | 🔴 Critical | 🏗️ Heavy lift

Root the input before the first collection point.

Line 305 runs after gc_collect_pending_suppressed_parse() at Line 208. That helper can call gc_check_trigger() and relocate the string. If it collects, the root records a from-space address and Line 316 reloads that stale address.

The tape branch at Lines 268-270 also runs before Line 305. Its helper has collection points before its local root. Therefore forced tape mode can still consume stale text_ptr and bytes.

Create the root before Line 208. Carry a rooted handle through the tape branch. Re-derive the pointer and byte slice after each collection. Restore the root on every return path. Add a from-space-quarantine test that exercises pending collection and PERRY_JSON_TAPE=1.

As per coding guidelines, GC-managed values must remain rooted across every possible collection point. Based on learnings, a movable source-string byte view must be reloaded after a GC.

🤖 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/parse_api.rs` around lines 289 - 305, Move
creation of the source-string root before the first collection point, including
gc_collect_pending_suppressed_parse(), and carry that rooted handle through the
tape branch. After every possible collection, re-derive text_ptr and bytes from
the rooted value before parsing, restore/pop the root on every return path, and
add a from-space-quarantine test covering pending collection with
PERRY_JSON_TAPE=1.

Sources: Coding guidelines, Learnings

@proggeramlug
proggeramlug merged commit 41d614d into main Aug 4, 2026
22 of 45 checks passed
@proggeramlug
proggeramlug deleted the fix/7341-json-parse-stale-input branch August 4, 2026 14:24
proggeramlug added a commit that referenced this pull request Aug 4, 2026
…r it was written (#7378)

#7341's quarantine produced 31 real stale-pointer bugs; four were fixed
(#7373-#7376). None existed when the RFC's 'would it have caught the real
bugs' table was written, so they are the strongest available calibration.

It would have caught ONE of the four. #7375 (await polling a moved
promise) is squarely in scope and would have been caught completely --
notably it had survived a comment explicitly reasoning about the
surrounding hazard, which is the RFC's own central argument. The other
three lived in perry-runtime, which this RFC does not govern.

Recording that honestly rather than only the win, because the ratio is
the useful part: it says where the remaining risk lives. Three of four
were layer 3, where RuntimeHandleScope exists (675 uses) but is optional.

The sharper finding is that all four were the SAME defect shape --
ordering a root relative to a collection point, never a missing root.
That is exactly what a Raw that dies at the next &mut emit enforces, so
the design generalises; the open question is whether layer 3 needs the
same discipline rather than whether codegen does.

Also notes the sample is favourable: the eight catches left open in #7341
are caller-side, and Raw<'e> does not cross a function boundary either.

No code change -- the RFC says step 1 wants a quiet tree, and expr/ is
under edit by #7375.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

1 participant