Skip to content

jsc: throw RangeError for deeply nested object/array literals instead of hanging - #34339

Closed
robobun wants to merge 3 commits into
mainfrom
farm/9b0a76df/jsc-parser-deep-nesting-hang
Closed

robobun wants to merge 3 commits into
mainfrom
farm/9b0a76df/jsc-parser-deep-nesting-hang

Conversation

@robobun

@robobun robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

Depends on oven-sh/WebKit#297. This PR bumps WEBKIT_VERSION to pick up that fix and adds a regression test; the preview release is autobuild-preview-pr-297-3946a08b.

Reproduction

python3 -c "print('const y = ' + '{a:'*2600 + '1' + '}'*2600 + ';')" > nest2600.js
bun nest2600.js

1.4: 99% CPU, RSS climbs unboundedly (~500MB/s), never terminates.
1.3.14: error: Maximum call stack size exceeded in ~25ms (Bun's parser had larger stack frames and rejected before handing to JSC).
Node: RangeError: Maximum call stack size exceeded immediately.

The same hang is reachable directly via eval() on both 1.3.14 and 1.4, bypassing Bun's transpiler entirely.

Cause

The hang is in JavaScriptCore's parser, not the bytecode compiler. When failIfStackOverflow() trips inside parseAssignmentExpression for a nested {a:{a:...}}, the failure propagates back to each enclosing level. At each level maybeAssignmentPattern is true (the token is {), so swapSavePointForError rewinds the lexer and clears m_errorMessage, then the same input is retried via tryParseDestructuringPatternExpression. That path reaches parseAssignmentElement, which on failure of its parseDestructuringPattern attempt calls restoreSavePoint (clearing the error again) and retries via parseMemberExpression -> parseObjectLiteral -> parseAssignmentExpression. Two alternate-production retries per nesting level multiply to exponential work; the process spins in Parser::logError / StringPrintStream::vprintf allocating error strings forever.

Sampled backtrace of the hang
WTF::StringPrintStream::vprintf                   StringPrintStream.cpp:64
WTF::PrintStream::printfVariableFormat            PrintStream.cpp:51
JSC::Parser<JSC::Lexer<unsigned char>>::logError  Parser.cpp:124

Fix

m_hasStackOverflow is already set by failWithStackOverflow() and is not touched by restoreSavePoint. oven-sh/WebKit#297 makes failIfStackOverflow() consult it, so the first overflow short-circuits every subsequent recursion entry (parseAssignmentExpression, parsePrimaryExpression, parseDestructuringPattern) and the failure propagates in O(depth).

Verification

With a local WebKit build carrying the fix:

file depth=2600   ok
file depth=3000   RangeError: Maximum call stack size exceeded.     (37ms)
file depth=100000 RangeError: Maximum call stack size exceeded.     (41ms)
eval depth=50000  RangeError: Maximum call stack size exceeded.

Destructuring, arrow functions, assignment patterns and array destructuring continue to parse normally. The new test drives the eval path so it exercises JSC directly regardless of Bun's transpiler stack-frame size; before the fix the spawned child is killed by the 20s timeout and the assertion fails on signalCode: "SIGTERM".


[decide:webkit] gate passed · iteration 0 · 2 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/jsc/parser-deep-nesting.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/jsc/parser-deep-nesting.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (ba687bb6f)

test/js/bun/jsc/parser-deep-nesting.test.ts:
(pass) deeply nested object literal passed to eval() throws RangeError instead of hanging [509.46ms]
(pass) deeply nested array literal passed to eval() throws RangeError instead of hanging [485.48ms]

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 1 file. [3.54s]
Exit: 0
diff hotspot
scripts/build/deps/webkit.ts                |  2 +-
 test/js/bun/jsc/parser-deep-nesting.test.ts | 45 +++++++++++++++++++++++++++++
 2 files changed, 46 insertions(+), 1 deletion(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                         reads  edits  tests
scripts/build/deps/webkit.ts                     3      2      0
test/js/bun/jsc/parser-deep-nesting.test.ts      1      3      0

…iterals

Bumps WebKit to the preview build carrying oven-sh/WebKit#297, which makes
failIfStackOverflow() in the JSC parser consult the sticky m_hasStackOverflow
flag. Without it, save-point backtracking clears the error at every nesting
level and retries the same input as a destructuring pattern and again as a
member expression, so a 10KB source with ~2600 levels of {a:{a:...}} reparses
exponentially and never terminates (observed as 99% CPU and ~500MB/s RSS
growth).

The regression test drives this via eval() so it exercises JSC directly on
every build; the file-load path now also throws RangeError cleanly instead of
hanging.
@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Build #73871 finished 283/286 green. The new parser-deep-nesting.test.ts passes on every lane.

The three red lanes are two pre-existing main breaks, both already owned by separate fix sessions and both present on all five of the most recently merged PRs' builds:

  • test/js/node/test/parallel/test-net-connect-memleak.js on Alpine 3.23 x64 and x64-baseline (GC-timing assertion; passes 30/30 locally against this WebKit)
  • test/cli/run/require-cache.test.ts on darwin 14 aarch64 (RSS-threshold check saw 73MB vs a 64MB ceiling)

WebKit preview build is autobuild-preview-pr-297-3946a08b. Once oven-sh/WebKit#297 merges, WEBKIT_VERSION here should be swapped from the preview tag to the resulting main sha.

The fix is entirely in the WebKit dependency (Parser.cpp failIfStackOverflow()), not in src/, so the gate's src/-stash fail-before check does not apply; same shape as #32184.

@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:48 AM PT - Jul 16th, 2026

❌ @robobun, your commit ba687bb has 1 failures in Build #73871 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34339

That installs a local version of the PR into your bun-34339 executable, so you can run:

bun-34339 --bun

@robobun
robobun marked this pull request as ready for review July 16, 2026 09:58
@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 81bb6e9c-c1cc-46d8-b25e-9166000140b8

📥 Commits

Reviewing files that changed from the base of the PR and between 4bbe075 and ba687bb.

📒 Files selected for processing (2)
  • scripts/build/deps/webkit.ts
  • test/js/bun/jsc/parser-deep-nesting.test.ts

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline nit, I also checked the stderr: "" assertion (bunEnv sets BUN_DEBUG_QUIET_LOGS=1, so it holds on debug lanes) and the explicit 30s test timeout (bounded by the 20s spawn timeout, needed so the pre-fix hang fails fast rather than stalling CI). The WEBKIT_VERSION bump to a preview tag is handled correctly by prebuiltUrl/prebuiltDestDir (both branch on the autobuild- prefix).

Extended reasoning...

The reported finding is a test-fixture arithmetic nit; the object-literal case still exercises ~16.6k levels, well past the ~3k threshold, so the test is not broken today. Separately verified: the autobuild-preview-* tag flows through scripts/build/deps/webkit.ts without issue (both the release URL and cache-dir key already special-case the autobuild- prefix), and the test's stderr/timeout patterns that CLAUDE.md usually flags were examined and found acceptable in this context. Not approving because this pins the JS engine to an unmerged WebKit PR's preview build — a maintainer should confirm the landing sequence with oven-sh/WebKit#297.

Comment thread test/js/bun/jsc/parser-deep-nesting.test.ts Outdated
…00 deep

Buffer.alloc(n, fill) fills n bytes, so a 3-byte fill only produced
~16667 '{a:' copies with a truncated tail while the close side had
50000 '}', leaving the object-literal fixture unbalanced and shallower
than intended.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks — ba687bb addresses the Buffer.alloc sizing issue; both fixtures are now balanced 50000-level nestings. I'm deferring to a human on the merge itself: this pins WEBKIT_VERSION to a preview tag for the still-open oven-sh/WebKit#297, and a maintainer should decide whether to land against the preview build or wait for that PR to merge and bump to the resulting sha.

Checked: the test now generates syntactically valid balanced input for both variants; prebuiltUrl/prebuiltDestDir already special-case autobuild--prefixed versions so the tag resolves correctly; the spawn drains stdout/stderr concurrently and asserts signalCode: null so a hang surfaces as a real failure.

Extended reasoning...

Overview

Two files: a one-line WEBKIT_VERSION bump in scripts/build/deps/webkit.ts (from sha 4895f45d… to preview tag autobuild-preview-pr-297-3946a08b), and a new 45-line regression test at test/js/bun/jsc/parser-deep-nesting.test.ts. The behavioral fix itself lives entirely in oven-sh/WebKit#297 (JSC Parser.cpp — making failIfStackOverflow() consult the sticky m_hasStackOverflow flag so save-point backtracking can't clear the overflow error and reparse exponentially).

Since my last review

I previously flagged that Buffer.alloc(50000, "{a:") fills 50000 bytes, not 50000 repetitions, leaving the object-literal fixture unbalanced at ~16667 levels. ba687bb changes the byte count to depth * open.length / depth * close.length, which produces exactly 50000 balanced repetitions for both the {a:/} and [/] variants. That resolves the concern; the thread is marked resolved.

Security risks

None identified. The change tightens behavior (a hang becomes a thrown RangeError) rather than relaxing any check. The test spawns bunExe() with bunEnv and a fixed inline script — no external input, network, or filesystem writes.

Level of scrutiny

High, because the substantive change is a JavaScriptCore parser modification pulled in via a dependency bump. The diff in this repo is trivial, but the linked WebKit change alters stack-overflow handling on a code path shared by destructuring patterns, arrow-function heuristics, and assignment expressions. That review belongs on oven-sh/WebKit#297; here a maintainer needs to sign off on shipping Bun against a preview WebKit build.

Other factors

  • WEBKIT_VERSION has never been merged to main pointing at an autobuild-preview-pr-* tag before (the autobuild- handling in prebuiltUrl/prebuiltDestDir landed in #34143 but main still pins a plain sha). Whether to land against a preview tag or wait for WebKit#297 to merge is a process call for a maintainer.
  • The PR description references tag …-8b31a3bc while the code pins …-3946a08b; the robobun status comment confirms 3946a08b is the published one, so the code is correct and the description is just stale.
  • The test itself looks solid post-fix: it drains both pipes concurrently with proc.exited, asserts the full {stdout, stderr, exitCode, signalCode} object so a timeout kill (SIGTERM) fails loudly, and drives eval() in a subprocess so it exercises JSC directly regardless of Bun's transpiler stack-frame size. Finder concerns about stderr: "" and the 30s per-test timeout were examined and ruled out by verifiers this run.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

This also fixes the assignment-context variant that was separately reported as a "codegen memory explosion":

const N = 3000;
new Function(`let q; (${"[".repeat(N)}q${"]".repeat(N)} = []);`)();
// peak RSS ~21 GB then SIGABRT on a release build

That report assumed the parser was fine because new Function(src) returns in ~2 ms, and blamed BytecodeGenerator::emitTryWithFinallyThatDoesNotShadowException for the blowup on the subsequent call. The body is actually lazy-parsed: new Function only syntax-checks the outer declaration, and the full ASTBuilder parse runs on first call. A sampled backtrace during the hang shows ~2900 frames cycling through parseAssignmentExpression -> swapSavePointForError -> tryParseDestructuringPatternExpression -> parseAssignmentElement -> restoreSavePoint -> parseMemberExpression -> parseArrayLiteral -> parseAssignmentExpression, the same retry loop this PR addresses. BytecodeGenerator is never reached.

The existing array-literal test here (const y = [[[...1...]]] via eval) exercises the identical parse path and already covers this case.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-16, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant