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
4 changes: 4 additions & 0 deletions changelog.d/7832-iterative-json-parse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**`JSON.parse` now handles deeply nested documents without exhausting a worker
thread's native stack.** Inputs beyond the recursive fast path use strict tape
validation and heap-backed iterative materialization, with a separate
500,000-level resource limit.
89 changes: 75 additions & 14 deletions crates/perry-runtime/src/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,7 @@ mod tests {
}
}

/// #7792 — deeply nested input must throw, not take the process out.
/// #7792 / #7817 — deeply nested input must not take the process out.
///
/// Both parsers that read the document recurse once per nesting level, so
/// a deep enough document exhausted the stack: SIGSEGV, exit 139, no
Expand All @@ -833,7 +833,17 @@ mod tests {
/// document is unusual.
mod nesting_depth {
use super::*;
use crate::json::parser::{nesting_depth_exceeds, MAX_NESTING_DEPTH};
use crate::json::parser::{
nesting_depth_exceeds, MAX_ITERATIVE_NESTING_DEPTH, MAX_RECURSIVE_NESTING_DEPTH,
};

fn nested_arrays(depth: usize, leaf: u8) -> Vec<u8> {
let mut input = Vec::with_capacity(depth * 2 + 1);
input.extend(std::iter::repeat_n(b'[', depth));
input.push(leaf);
input.extend(std::iter::repeat_n(b']', depth));
input
}

#[test]
fn the_scan_counts_only_structural_brackets() {
Expand All @@ -855,28 +865,79 @@ mod tests {
assert!(!nesting_depth_exceeds(b"", 0));
}

/// The limit is the point of the change, so pin the boundary itself:
/// one level under passes, one level over is refused.
/// Pin the parser handoff boundary: both sides produce a value.
#[test]
fn parse_refuses_input_past_the_limit_and_accepts_input_under_it() {
let ok_depth = MAX_NESTING_DEPTH - 1;
let mut ok = vec![b'['; ok_depth];
ok.extend(std::iter::repeat(b']').take(ok_depth));
fn parse_switches_to_the_iterative_path_past_the_recursive_threshold() {
let ok_depth = MAX_RECURSIVE_NESTING_DEPTH - 1;
let ok = nested_arrays(ok_depth, b'0');
let text = js_string_from_bytes(ok.as_ptr(), ok.len() as u32);
assert!(
unsafe { js_json_parse_result(text) }.is_ok(),
"input inside the limit must still parse"
"input below the handoff must parse"
);

let deep_depth = MAX_NESTING_DEPTH + 1;
let mut deep = vec![b'['; deep_depth];
deep.extend(std::iter::repeat(b']').take(deep_depth));
let deep_depth = MAX_RECURSIVE_NESTING_DEPTH + 1;
let deep = nested_arrays(deep_depth, b'0');
let text = js_string_from_bytes(deep.as_ptr(), deep.len() as u32);
assert!(
unsafe { js_json_parse_result(text) }.is_err(),
"input past the limit must be refused rather than descended into"
unsafe { js_json_parse_result(text) }.is_ok(),
"input above the handoff must parse through the heap-stack path"
);
}

#[test]
fn parses_three_hundred_thousand_levels_on_a_small_worker_stack() {
const DEPTH: usize = 300_000;
std::thread::Builder::new()
.name("json-deep-worker".into())
.stack_size(2 * 1024 * 1024)
.spawn(|| {
let input = nested_arrays(DEPTH, b'7');
let text = js_string_from_bytes(input.as_ptr(), input.len() as u32);
let mut value = unsafe { js_json_parse_result(text) }
.expect("deep JSON must parse on a worker-sized stack");

for level in 0..DEPTH {
assert!(value.is_pointer(), "level {level} must be an array");
let array = (value.bits() & POINTER_MASK) as *const crate::ArrayHeader;
assert_eq!(unsafe { (*array).length }, 1, "level {level}");
value = crate::array::js_array_get(array, 0);
}
assert_eq!(f64::from_bits(value.bits()), 7.0);
})
.expect("worker thread starts")
.join()
.expect("worker parse does not panic");
}
Comment on lines +888 to +911

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether perry-runtime supports allocation and GC on non-main threads.
set -euo pipefail

rg -nP -C 6 '\bfn\s+gc_init\s*\(' crates/perry-runtime/src
rg -nP -C 6 'main_thread|is_main_thread|runtime_main_thread' crates/perry-runtime/src --glob '*.rs'

# Do any other perry-runtime tests spawn threads and allocate?
rg -nP -C 6 'thread::Builder::new|std::thread::spawn' crates/perry-runtime/src --glob '*.rs'

# How are perry-runtime tests actually invoked?
rg -nP -C 3 'RUST_TEST_THREADS' --hidden

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- gc initialization ---'
sed -n '620,735p' crates/perry-runtime/src/gc/mod.rs
sed -n '570,680p' crates/perry-runtime/src/gc/schedule.rs

echo '--- initialization and allocation call sites ---'
rg -n 'ensure_gc_initialized|gc_init\(|gc_check_trigger|gc_schedule|alloc|allocate' crates/perry-runtime/src/gc crates/perry-runtime/src/json/mod.rs --glob '*.rs' \
  | head -n 240

echo '--- test and runtime thread usage ---'
rg -n 'thread::Builder::new|std::thread::spawn|spawn\(\|\||js_json_parse_result|js_string_from_bytes|js_array_get' crates/perry-runtime/src --glob '*.rs' \
  | head -n 240

echo '--- test-thread configuration ---'
rg -n 'RUST_TEST_THREADS|cargo test.*perry-runtime|perry-runtime.*test' . --hidden \
  -g '!target/**' -g '!node_modules/**' | head -n 160

echo '--- json test context ---'
sed -n '820,930p' crates/perry-runtime/src/json/mod.rs

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- gc initialization ---'
sed -n '620,735p' crates/perry-runtime/src/gc/mod.rs
sed -n '570,680p' crates/perry-runtime/src/gc/schedule.rs

echo '--- initialization and allocation call sites ---'
rg -n 'ensure_gc_initialized|gc_init\(|gc_check_trigger|gc_schedule|alloc|allocate' crates/perry-runtime/src/gc crates/perry-runtime/src/json/mod.rs --glob '*.rs' \
  | head -n 240

echo '--- test and runtime thread usage ---'
rg -n 'thread::Builder::new|std::thread::spawn|js_json_parse_result|js_string_from_bytes|js_array_get' crates/perry-runtime/src --glob '*.rs' \
  | head -n 240

echo '--- test-thread configuration ---'
rg -n 'RUST_TEST_THREADS|cargo test.*perry-runtime|perry-runtime.*test' . --hidden \
  -g '!target/**' -g '!node_modules/**' | head -n 160

echo '--- json test context ---'
sed -n '820,930p' crates/perry-runtime/src/json/mod.rs

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- string allocation path ---'
rg -n -C 12 'pub extern .*js_string_from_bytes|pub fn js_string_from_bytes|fn js_string_from_bytes|ensure_gc_initialized' \
  crates/perry-runtime/src/string.rs crates/perry-runtime/src/array.rs crates/perry-runtime/src/object.rs \
  crates/perry-runtime/src/gc/mod.rs

echo '--- JSON parse entry and allocation helpers ---'
rg -n -C 16 'js_json_parse_result|parse_json|parse_value|js_array_alloc|js_array_alloc_with_length|gc_malloc' \
  crates/perry-runtime/src/json/mod.rs --glob '*.rs' | head -n 320

echo '--- deep test and nearby setup ---'
rg -n -C 24 'parses_three_hundred_thousand_levels_on_a_small_worker_stack|nested_arrays|json-deep-worker' \
  crates/perry-runtime/src/json/mod.rs

echo '--- spawned-thread tests that allocate runtime values ---'
rg -n -C 16 'thread::Builder::new|std::thread::spawn' crates/perry-runtime/src \
  --glob '*.rs' \
  -g '!CHANGELOG.md' \
  | rg -n -B 16 -A 16 'test|js_string_from_bytes|js_array_alloc|gc_malloc|js_json_parse_result' \
  | head -n 320

echo '--- runtime initialization documentation ---'
rg -n -C 8 'spawned worker|worker thread|every thread|current thread|thread-local scanner|allocation.*thread' \
  crates/perry-runtime/src/gc crates/perry-runtime/src/string.rs crates/perry-runtime/src/json/mod.rs \
  --glob '*.rs' | head -n 240

Repository: PerryTS/perry

Length of output: 5743


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- module files ---'
fd -t f . crates/perry-runtime/src | rg '/(string|array|object)(/mod)?\.rs$|json/mod\.rs$'

echo '--- initialization call sites ---'
rg -n -C 10 'ensure_gc_initialized' crates/perry-runtime/src --glob '*.rs' \
  -g '!CHANGELOG.md' | head -n 260

echo '--- string and array allocation definitions ---'
rg -n -C 18 'js_string_from_bytes|js_array_alloc_with_length|js_array_alloc\(' \
  crates/perry-runtime/src/string crates/perry-runtime/src/array \
  --glob '*.rs' | head -n 360

echo '--- JSON parse definitions and deep test ---'
rg -n -C 20 'js_json_parse_result|parses_three_hundred_thousand_levels_on_a_small_worker_stack|nested_arrays' \
  crates/perry-runtime/src/json/mod.rs | head -n 300

echo '--- exact runtime test thread calls ---'
rg -n -C 14 'thread::Builder::new|std::thread::spawn' crates/perry-runtime/src \
  --glob '*.rs' -g '!CHANGELOG.md' \
  | head -n 360

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- string allocation implementation ---'
rg -n 'fn string_storage_alloc|fn js_string_from_bytes|pub .*js_string_from_bytes|gc_malloc|ensure_gc_initialized' \
  crates/perry-runtime/src/string/alloc.rs crates/perry-runtime/src/string/mod.rs
sed -n '1,220p' crates/perry-runtime/src/string/alloc.rs

echo '--- array allocation implementation ---'
rg -n 'fn js_array_alloc|pub .*js_array_alloc|gc_malloc|ensure_gc_initialized' \
  crates/perry-runtime/src/array/alloc.rs
sed -n '1,180p' crates/perry-runtime/src/array/alloc.rs

echo '--- JSON parse entry points ---'
rg -n 'js_json_parse_result|js_json_parse|parse_json|parse_value|js_get_global_this|ensure_gc_initialized' \
  crates/perry-runtime/src/json/mod.rs
sed -n '2200,2340p' crates/perry-runtime/src/json/mod.rs

echo '--- deep test exact context ---'
rg -n 'parses_three_hundred_thousand_levels_on_a_small_worker_stack|nested_arrays|json-deep-worker' \
  crates/perry-runtime/src/json/mod.rs

Repository: PerryTS/perry

Length of output: 19795


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- JSON module files and parse symbol ---'
fd -t f . crates/perry-runtime/src/json
rg -n -C 20 'pub .*js_json_parse_result|fn js_json_parse_result|js_json_parse_result' \
  crates/perry-runtime/src/json --glob '*.rs'

echo '--- deep test context ---'
sed -n '828,915p' crates/perry-runtime/src/json/mod.rs

echo '--- storage allocation implementation ---'
sed -n '470,530p' crates/perry-runtime/src/string/mod.rs
rg -n -C 12 'fn arena_alloc_gc|pub .*arena_alloc_gc|ensure_gc_initialized' \
  crates/perry-runtime/src/arena crates/perry-runtime/src/gc --glob '*.rs' | head -n 220

echo '--- parse initialization references ---'
rg -n -C 10 'ensure_gc_initialized|js_get_global_this|gc_init|gc_check_trigger' \
  crates/perry-runtime/src/json --glob '*.rs'

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

json = Path("crates/perry-runtime/src/json/mod.rs").read_text()
parse = Path("crates/perry-runtime/src/json/parse_api.rs").read_text()
string_alloc = Path("crates/perry-runtime/src/string/alloc.rs").read_text()
arena = Path("crates/perry-runtime/src/arena/allocators.rs").read_text()
thread = Path("crates/perry-runtime/src/thread.rs").read_text()
gc = Path("crates/perry-runtime/src/gc/mod.rs").read_text()

worker = re.search(
    r'fn parses_three_hundred_thousand_levels_on_a_small_worker_stack\(\).*?'
    r'\.spawn\(\|\| \{(?P<body>.*?)\n\s*\}\)\s*\.expect',
    json,
    re.S,
)
assert worker, "deep worker test not found"
worker_body = worker.group("body")

string_fn = re.search(
    r'pub extern "C" fn js_string_from_bytes\(.*?\n\}',
    string_alloc,
    re.S,
)
assert string_fn
string_body = string_fn.group(0)

parse_fn = re.search(
    r'pub unsafe fn js_json_parse_result\(.*?(?=\n\}\n)',
    parse,
    re.S,
)
assert parse_fn
parse_body = parse_fn.group(0)

arena_fn = re.search(
    r'pub fn arena_alloc_gc\(.*?(?=\n\}\n)',
    arena,
    re.S,
)
assert arena_fn
arena_body = arena_fn.group(0)

print("worker calls ensure_gc_initialized:",
      "ensure_gc_initialized" in worker_body)
print("worker allocates string before parse:",
      worker_body.index("js_string_from_bytes") < worker_body.index("js_json_parse_result"))
print("js_string_from_bytes calls ensure_gc_initialized:",
      "ensure_gc_initialized" in string_fn.group(0))
print("js_json_parse_result calls ensure_gc_initialized:",
      "ensure_gc_initialized" in parse_fn.group(0))
print("js_json_parse_result calls gc_check_trigger:",
      "gc_check_trigger" in parse)
print("arena_alloc_gc calls ensure_gc_initialized:",
      "ensure_gc_initialized" in arena_fn.group(0))
print("runtime worker initializes before allocation:",
      thread.index("ensure_gc_initialized") < thread.index("js_closure_alloc"))
print("GC initialization state is thread-local:",
      "thread_local!" in gc and "GC_INIT_DONE" in gc)
PY

Repository: PerryTS/perry

Length of output: 546


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

json = Path("crates/perry-runtime/src/json/mod.rs").read_text()
parse = Path("crates/perry-runtime/src/json/parse_api.rs").read_text()
string_alloc = Path("crates/perry-runtime/src/string/alloc.rs").read_text()
arena = Path("crates/perry-runtime/src/arena/allocators.rs").read_text()
thread = Path("crates/perry-runtime/src/thread.rs").read_text()
gc = Path("crates/perry-runtime/src/gc/mod.rs").read_text()

worker = re.search(
    r'fn parses_three_hundred_thousand_levels_on_a_small_worker_stack\(\).*?'
    r'\.spawn\(\|\| \{(?P<body>.*?)\n\s*\}\)\s*\.expect',
    json,
    re.S,
)
assert worker, "deep worker test not found"
worker_body = worker.group("body")

string_fn = re.search(
    r'pub extern "C" fn js_string_from_bytes\(.*?\n\}',
    string_alloc,
    re.S,
)
assert string_fn

parse_fn = re.search(
    r'pub unsafe fn js_json_parse_result\(.*?(?=\n\}\n)',
    parse,
    re.S,
)
assert parse_fn

arena_fn = re.search(
    r'pub fn arena_alloc_gc\(.*?(?=\n\}\n)',
    arena,
    re.S,
)
assert arena_fn

print("worker calls ensure_gc_initialized:",
      "ensure_gc_initialized" in worker_body)
print("worker allocates string before parse:",
      worker_body.index("js_string_from_bytes") < worker_body.index("js_json_parse_result"))
print("js_string_from_bytes calls ensure_gc_initialized:",
      "ensure_gc_initialized" in string_fn.group(0))
print("js_json_parse_result calls ensure_gc_initialized:",
      "ensure_gc_initialized" in parse_fn.group(0))
print("js_json_parse_result calls gc_check_trigger:",
      "gc_check_trigger" in parse)
print("arena_alloc_gc calls ensure_gc_initialized:",
      "ensure_gc_initialized" in arena_fn.group(0))
print("runtime worker initializes before allocation:",
      thread.index("ensure_gc_initialized") < thread.index("js_closure_alloc"))
print("GC initialization state is thread-local:",
      "thread_local!" in gc and "GC_INIT_DONE" in gc)
PY

Repository: PerryTS/perry

Length of output: 546


Initialize GC on the spawned worker before runtime allocation. ensure_gc_initialized() is thread-local. This closure calls js_string_from_bytes before parsing, and js_json_parse_result can trigger GC. Call crate::gc::ensure_gc_initialized() before these calls. RUST_TEST_THREADS=1 does not replace per-thread initialization.

🤖 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/mod.rs` around lines 888 - 911, Initialize the
thread-local GC at the start of the closure in
parses_three_hundred_thousand_levels_on_a_small_worker_stack by calling
crate::gc::ensure_gc_initialized() before nested_arrays, js_string_from_bytes,
or js_json_parse_result perform runtime allocation. Keep the existing parsing
and validation flow unchanged.

Sources: Coding guidelines, Learnings


#[test]
fn rejects_nesting_beyond_the_iterative_resource_budget() {
let input = nested_arrays(MAX_ITERATIVE_NESTING_DEPTH + 1, b'0');
let text = js_string_from_bytes(input.as_ptr(), input.len() as u32);
let error = unsafe { js_json_parse_result(text) }
.expect_err("the iterative path must keep a finite resource budget");
let error = (error.to_bits() & POINTER_MASK) as *const crate::error::ErrorHeader;
assert_eq!(
unsafe { (*error).error_kind },
crate::error::ERROR_KIND_RANGE_ERROR
);
}

#[test]
fn iterative_path_still_rejects_malformed_json() {
let depth = MAX_RECURSIVE_NESTING_DEPTH + 1;
let mut trailing = nested_arrays(depth, b'0');
trailing.push(b'x');
let mut invalid_number = Vec::with_capacity(depth * 2 + 2);
invalid_number.extend(std::iter::repeat_n(b'[', depth));
invalid_number.extend_from_slice(b"01");
invalid_number.extend(std::iter::repeat_n(b']', depth));

for input in [trailing, invalid_number] {
let text = js_string_from_bytes(input.as_ptr(), input.len() as u32);
assert!(unsafe { js_json_parse_result(text) }.is_err());
}
}
}

#[test]
Expand Down
104 changes: 81 additions & 23 deletions crates/perry-runtime/src/json/parse_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,6 @@ fn syntax_error_value(message: &str) -> f64 {
f64::from_bits(JSValue::pointer(err as *const u8).bits())
}

/// A catchable `RangeError`, for the one JSON failure that is about size
/// rather than shape: input nested deeper than the parser can descend.
fn range_error_value(message: &str) -> f64 {
let msg_ptr = js_string_from_bytes(message.as_ptr(), message.len() as u32);
let err = crate::error::js_rangeerror_new(msg_ptr);
Expand All @@ -85,21 +83,32 @@ fn throw_range_error(message: &str) -> ! {
crate::exception::js_throw(range_error_value(message))
}

/// The one depth check, called by every entry that is about to descend.
/// Select the heap-stack parser before recursive validation or materialization
/// gets close to the smallest worker-thread stack.
///
/// `js_json_parse` and `js_json_parse_result` are separate implementations of
/// the same flow, and the typed-array path is a third. Sharing the decision is
/// what keeps them from drifting — the first version of this fix guarded only
/// one of the three and appeared to do nothing at all, because the entry point
/// codegen actually calls was one of the other two.
fn nesting_is_too_deep(bytes: &[u8]) -> bool {
crate::json::parser::nesting_depth_exceeds(bytes, crate::json::parser::MAX_NESTING_DEPTH)
fn requires_iterative_parse(bytes: &[u8]) -> bool {
crate::json::parser::nesting_depth_exceeds(
bytes,
crate::json::parser::MAX_RECURSIVE_NESTING_DEPTH,
)
}

fn too_deep_message() -> String {
fn exceeds_iterative_budget(bytes: &[u8]) -> bool {
crate::json::parser::nesting_depth_exceeds(
bytes,
crate::json::parser::MAX_ITERATIVE_NESTING_DEPTH,
)
}

fn iterative_budget_message() -> String {
format!(
"JSON.parse: input nested deeper than {} levels",
crate::json::parser::MAX_NESTING_DEPTH
"JSON.parse: input exceeds the {}-level iterative nesting budget",
crate::json::parser::MAX_ITERATIVE_NESTING_DEPTH
)
}

Expand All @@ -118,6 +127,53 @@ fn is_json_null_literal(bytes: &[u8]) -> bool {
&bytes[start..end] == b"null"
}

/// Parse a deeply nested document through the flat tape representation. Tape
/// construction validates syntax with an explicit heap stack; materialization
/// likewise keeps pending containers on the heap. This path runs only beyond
/// the recursive fast path's safe depth, so ordinary JSON keeps its existing
/// allocation and shape-specialization behavior.
unsafe fn try_parse_deep_iterative(
text_ptr: *const StringHeader,
len: usize,
bytes: &[u8],
) -> Option<JSValue> {
let text_root = parse_root_push(JSValue::string_ptr(text_ptr as *mut StringHeader));
let result = crate::json_tape::with_built_tape(bytes, |tape_entries| {
crate::gc::gc_collect_pending_suppressed_parse();
crate::gc::gc_check_trigger();
crate::gc::gc_suppress();

let bytes = {
let moved = 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 result = crate::json_tape::materialize_iterative(tape_entries, bytes);
if let Some(value) = result {
parse_root_push(value);
}

crate::gc::gc_unsuppress();
crate::gc::gc_bump_malloc_trigger();
crate::gc::gc_schedule_parse_boundary_collection_if_pressure();
result
})
.flatten();
parse_root_restore(text_root);

PARSE_KEY_CACHE.with(|cell| {
let cache = cell.borrow();
if cache.len() > 4096 {
drop(cache);
cell.borrow_mut().clear();
clear_parse_key_ring();
}
});

result
}

/// Non-throwing JSON parse entry for APIs that must reject a Promise rather than
/// synchronously throwing through `JSON.parse`'s FFI boundary.
///
Expand All @@ -136,12 +192,12 @@ pub unsafe fn js_json_parse_result(text_ptr: *const StringHeader) -> Result<JSVa
return Err(syntax_error_value("Unexpected end of JSON input"));
}

// #7792: depth first, BEFORE the validation pass below. That pass recurses
// once per nesting level itself, so a check placed after it would run after
// the crash it exists to prevent. The scan is one linear pass over bytes we
// are about to read anyway.
if nesting_is_too_deep(bytes) {
return Err(range_error_value(&too_deep_message()));
if requires_iterative_parse(bytes) {
if exceeds_iterative_budget(bytes) {
return Err(range_error_value(&iterative_budget_message()));
}
return try_parse_deep_iterative(text_ptr, len, bytes)
.ok_or_else(|| syntax_error_value("JSON parse error: malformed deep document"));
}

// Validate without constructing a second full JSON tree. The Perry parser
Expand Down Expand Up @@ -236,11 +292,14 @@ pub unsafe extern "C" fn js_json_parse(text_ptr: *const StringHeader) -> JSValue
if len == 0 {
throw_syntax_error("Unexpected end of JSON input");
}
// #7792: depth first, ahead of the validation pass, for the same reason as
// the `_result` twin above. This is the entry codegen emits, so a guard
// that covered only the twin covered nothing a compiled program can reach.
if nesting_is_too_deep(bytes) {
throw_range_error(&too_deep_message());
if requires_iterative_parse(bytes) {
if exceeds_iterative_budget(bytes) {
throw_range_error(&iterative_budget_message());
}
return match try_parse_deep_iterative(text_ptr, len, bytes) {
Some(value) => value,
None => throw_syntax_error("JSON parse error: malformed deep document"),
};
}
// Keep serde_json's strict syntax validation, but discard tokens as they
// are read instead of allocating an intermediate `serde_json::Value`
Expand Down Expand Up @@ -559,10 +618,9 @@ pub unsafe extern "C" fn js_json_parse_typed_array(
let data_ptr = (text_ptr as *const u8).add(std::mem::size_of::<StringHeader>());
let bytes = std::slice::from_raw_parts(data_ptr, len);

// #7792: this path builds its own parser, so it needs its own guard. Hand
// deep input to the generic entry rather than repeating the error here, so
// both report it identically.
if nesting_is_too_deep(bytes) {
// Deep input uses the generic entry's heap-stack fallback. The shape fast
// path is deliberately retained for ordinary payloads only.
if requires_iterative_parse(bytes) {
return js_json_parse(text_ptr);
}

Expand Down
34 changes: 12 additions & 22 deletions crates/perry-runtime/src/json/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,30 +56,20 @@ pub(crate) struct ObjectShapeHint {
pub(crate) field_count: u32,
}

/// The deepest `[`/`{` nesting `JSON.parse` will accept.
/// The deepest `[`/`{` nesting handled by the recursive fast path.
///
/// Both parsers that see the input recurse once per level — the `serde_json`
/// validation pass and Perry's own value parser — so a deep enough document
/// exhausts the stack and takes the whole process out with SIGSEGV, no
/// diagnostic and no output, on input that is very often attacker-supplied
/// (#7792). Measured on a default 8 MB main-thread stack the crash lands
/// between 20,000 and 40,000 levels — but that is the most generous stack in
/// the process, and it is the wrong one to size against. Perry parses JSON on
/// `perry/thread` workers and tokio workers too, and a 2 MiB thread stack
/// overflows well before 10,000 levels: a first attempt at this limit picked
/// 10,000 off the main-thread measurement, and the unit test below promptly
/// crashed the test harness at 9,999.
/// Both the `serde_json` validation pass and Perry's direct value parser recurse
/// once per container, so their cutoff is sized for Perry's smallest worker
/// stack rather than the main thread's larger stack (#7792).
///
/// So the limit is sized for the SMALLEST stack in the process, not the
/// largest, and 1,000 is the same depth Python's parser has settled on. Real
/// documents do not come close: JSON nested past a hundred levels is already
/// unusual, and past a thousand is a machine talking to itself.
///
/// This is a deliberate parity gap. Node parses far deeper than this because
/// V8's parser is iterative and does not consume stack per level; matching it
/// means making this parser iterative too, which is the follow-up. Until then
/// a catchable error beats a SIGSEGV on untrusted input.
pub(crate) const MAX_NESTING_DEPTH: usize = 1_000;
/// Deeper documents switch to the flat-tape parser and iterative materializer,
/// so this is a native-stack safety threshold rather than an input limit.
pub(crate) const MAX_RECURSIVE_NESTING_DEPTH: usize = 1_000;

/// Heap-stack safety ceiling. This remains well above Node-parity cases such as
/// #7817's 300,000-level document, while bounding the tape, pending-frame stack,
/// and runtime-container amplification for unusually deep input.
pub(crate) const MAX_ITERATIVE_NESTING_DEPTH: usize = 500_000;

/// Does `bytes` nest deeper than `limit`?
///
Expand Down
Loading
Loading