Skip to content

json: make JSON.parse iterative so nesting depth is not bounded by thread stack size #7817

Description

@jdalton

Summary

Perry's JSON.parse descends one function call per nesting level, so how deep a document it can read is bounded by how much stack the thread has. #7816 stops that from being a crash — input past 1,000 levels now throws a catchable RangeError instead of taking the process out with SIGSEGV — but it does not close the gap against Node, and this issue is that gap.

node 26.5.1 perry after #7816
1,000 levels parses parses
1,001 levels parses RangeError
300,000 levels parses RangeError

Node reads all of these because V8's JSON parser is iterative — it keeps its nesting state on the heap, so depth costs memory rather than stack. Perry's is recursive, so depth costs stack, and stack is the scarcest resource in the process.

Why the limit is as low as it is

It is sized for the smallest stack Perry parses on, not the biggest.

On the main thread's 8 MB stack the crash lands somewhere between 20,000 and 40,000 levels. But Perry also parses JSON on perry/thread workers and tokio workers, and a 2 MiB thread stack gives out far sooner. I found that the hard way: #7816 first used a limit of 10,000 taken from a main-thread measurement, and its own unit test crashed the test harness at 9,999 levels.

So 1,000 is not a considered parity target — it is what a small stack can carry with room to spare. Any recursive parser has this shape: the safe limit is set by the worst stack in the process, and it cannot be raised by measuring on the best one.

What closing it takes

Both parsers that read the text recurse, so both have to change:

The two descents, and the constraint each one carries

Perry's value parser (crates/perry-runtime/src/json/parser.rs) — parse_value calls parse_array and parse_object, which call parse_value again. Making it iterative means an explicit stack of in-progress containers on the heap, with each completed value pushed into its parent.

The GC contract is the part to get right rather than the control flow. The parse currently runs under gc_suppress() with intermediates held in PARSE_ROOTS, and an explicit work stack holds partially-built arrays and objects across every step. Those need to stay reachable for the same reasons the recursive version's locals do, and the recent from-space quarantine work is a reminder of how a half-built object that is rooted late reads as fine right up until it does not.

The serde_json validation pass (js_json_parse / js_json_parse_result) — serde_json::from_slice::<IgnoredAny> recurses per level too, so an iterative Perry parser alone would not raise the ceiling by one level. The options are to replace that pass with a non-recursive validator, or to fold validation into the iterative parser so the text is read once instead of twice. Folding it in is appealing beyond depth: it would drop a full pass over the input.

What "done" should mean
  • A document nested well past today's limit — 300,000 levels, matching the original report in #7792 — parses to the same value Node produces.
  • It parses on a worker thread, not just the main thread, since that is the case that set the current limit. A test that only exercises the main thread would repeat the mistake that made 10,000 look safe.
  • The depth guard and its RangeError either come out entirely, or stay only as a memory-pressure bound with a limit that no longer depends on stack size.
  • No regression on ordinary payloads. Most real JSON is a few levels deep and heavily object-shaped, so the shape to watch is many small records rather than deep nesting — an iterative parser should not cost anything there, but "should not" is worth measuring.

Scope note

Not urgent. The crash is what mattered, and #7816 handles it; what is left is a parity gap on documents that are already unusual. Filing it so the 1,000 limit is a recorded decision with a reason attached rather than a number someone finds later and wonders about.

Refs #7792, #7816.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions