Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions changelog.d/7373-json-parse-stale-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
### Fixed

- **`JSON.parse` could read its input after the source string moved.** 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 that sheds parse-churn garbage between iterations — and only
*then* pushed the string's GC root and suppressed collection. An evacuating
minor at that trigger moved the string, leaving the parser reading retired
from-space for the entire parse.

The root now precedes the trigger, and the slice is re-derived from the rooted
value afterwards. Ordering is the whole fix: pushing the root *after* the
collection roots an address the collector has already moved away from, so
re-deriving from that slot returns the same stale pointer — verified by trying
exactly that first and measuring no change.

Invisible from output, because evacuation copies rather than zeroes and the
stale address still held the right bytes. Found with
`PERRY_GC_PROTECT_FROMSPACE=1`, which faults on the first `peek()`.

Closes **8 of the 31** remaining quarantine catches (#7341) — the largest
single cluster.
64 changes: 62 additions & 2 deletions crates/perry-runtime/src/json/parse_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,48 @@ pub unsafe fn js_json_parse_result(text_ptr: *const StringHeader) -> Result<JSVa
return Err(syntax_error_value(&format!("JSON parse error: {}", err)));
}

// #7341: root the source string BEFORE the collection points, then
// re-derive the input slice from the rooted value.
//
// The order used to be: derive `bytes`, run `serde_json::from_slice` (which
// allocates and arms the malloc trigger), call `gc_check_trigger()` (which
// can collect outright), suppress, and only THEN push the root. Two things
// went wrong at once. The slice predated a collection point, and — the part
// that makes re-deriving alone useless — so did the root: pushing
// `text_ptr` after the collection roots an address the collector has
// already moved away from, so reading it back yields the same stale
// pointer. The parser then reads retired from-space for the whole parse,
// which the quarantine reports as a fault at `parse_value + 36`, on the
// very first `peek()`.
//
// Rooting first means the collector rewrites the slot, so the re-read below
// yields the post-move payload address. The suppression that follows was
// already here and was never the bug.
let text_root = parse_root_push(JSValue::string_ptr(text_ptr as *mut StringHeader));

crate::gc::gc_collect_pending_suppressed_parse();
crate::gc::gc_check_trigger();
crate::gc::gc_suppress();

let text_root = parse_root_push(JSValue::string_ptr(text_ptr as *mut StringHeader));
//
// `bytes` above was taken from the StringHeader's payload before two
// collection points ran: `serde_json::from_slice` allocates (arming the
// malloc trigger), and `gc_check_trigger` can collect outright. An
// evacuating minor in either moves the source string, and the parser then
// reads the pre-collection address for the whole parse — the from-space
// quarantine reports it as a fault at `parse_value + 36`, on the very first
// `peek()`.
//
// The suppression was already here and is not the bug; the bug is that the
// borrow predates it. `text_root` keeps the string alive and the collector
// rewrites that root, so re-reading the header now yields the post-move
// payload address.
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)
};
Comment on lines +152 to +157

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

let mut parser = DirectParser::new(bytes);
let result = parser.parse_value();
parse_root_push(result);
Expand Down Expand Up @@ -249,6 +286,24 @@ pub unsafe extern "C" fn js_json_parse(text_ptr: *const StringHeader) -> JSValue
// in `gc_check_trigger` protects adversarial cases (previous stringify
// result strings sharing blocks with interned keys) from retrigger
// thrash when block-persistence keeps everything alive.
// #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));
Comment on lines +289 to +305

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


crate::gc::gc_check_trigger();

// Suppress GC for the duration of the parse. Parse is synchronous and
Expand All @@ -257,7 +312,12 @@ pub unsafe extern "C" fn js_json_parse(text_ptr: *const StringHeader) -> JSValue
// cycles walking an ever-growing live set (issue #59).
crate::gc::gc_suppress();

let text_root = parse_root_push(JSValue::string_ptr(text_ptr as *mut StringHeader));
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)
};

let mut parser = DirectParser::new(bytes);
let result = parser.parse_value();
Expand Down
Loading