Skip to content

response.test: reach the stack limit without creating a Response per frame - #37910

Open
robobun wants to merge 1 commit into
mainfrom
farm/f5b02fc4/response-test-stack-overflow-speed
Open

robobun wants to merge 1 commit into
mainfrom
farm/f5b02fc4/response-test-stack-overflow-speed

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun bd test test/js/web/fetch/response.test.ts fails on main: (fail) handle stack overflow / this test timed out after 5000ms. The test takes 5 to 8 seconds on a debug build (~365ms on the release binary), so the file is not a usable pass/fail signal for changes to Response when run the way CLAUDE.md says to.
  • The test (added in Fix unhandled exception in JSC__JSPromise__wrap when resolving promise #23961) creates a Response and calls .text() in every frame of a recursion that only overflows after ~26k frames (test/js/web/fetch/response.test.ts:152 on main). A debug build spends ~0.2ms per frame in the constructor and text(), which is where the time goes.
  • CI does not see this because scripts/runner.node.mjs passes a much larger per-test timeout.
  • Same file, second source of spurious diffs: print size inspects Bun.file(import.meta.filename), so its inline snapshot encodes the byte size of the test file itself and has had to be bumped by every change to this file so far (6 of 6, and again by this one).

Fix

  • handle stack overflow now recurses with empty frames, catches the overflow in the deepest frame, and calls new Response().text() only from the 32 deepest frames on the way back out. It asserts that exactly one RangeError: Maximum call stack size exceeded. was thrown and that all 32 promises resolve to "".
  • This keeps what Fix unhandled exception in JSC__JSPromise__wrap when resolving promise #23961 needs: the deepest frame is, by construction, the one where the JS stack is exhausted (its own recursive call just failed the stack check), so the native text() path runs with no JS stack left in each of those frames. The old shape only reached that state in the last few of its ~26k frames; the rest were padding.
  • Host calls do not check the JS stack limit themselves, so the number of caught errors is deterministic. Checked with the JIT disabled and with ulimit -s 1024 and unlimited as well.
  • The old test only asserted that something threw the overflow message; it never looked at what text() returned in the frames at the limit. The new one does.
  • For what it is worth, on the JSC we ship today neither shape reaches the exact branch Fix unhandled exception in JSC__JSPromise__wrap when resolving promise #23961 added: JSPromise::resolvedPromise is now plain C++ for a non-object result (vendor/WebKit/Source/JavaScriptCore/runtime/JSPromise.cpp, promiseResolve -> resolvePromise -> fulfillPromise) and cannot throw there, so on main text() in the deepest frames returns fulfilled promises too (checked with Bun.peek.status on both builds), and the only overflow the old test saw came from its own JS recursion. What both shapes verify is that the native path survives being called with the JS stack exhausted; the new one does it at the deepest frame deterministically and checks the promises it gets back.
  • print size inspects a fixture of fixed size in a tempDir instead of the test file, so the snapshot stops depending on this file's length. (It also passes the directory to normalizeBunSnapshot; before, import.meta.dir was being passed to expect() by mistake, and the <cwd> replacement happened to cover for it.)
  • Verified: bun bd test test/js/web/fetch/response.test.ts passes 23/23. handle stack overflow takes 36 to 51ms on the debug build and 4 to 7ms on release. 10 debug runs and 30 release runs of the file, all green.
  • Related: Drop the body when a Response is constructed with a null body status #32044 fattens this test's frames as a side change of an unrelated fix (still ~600 Response allocations per run, ~0.6s on debug); Response.redirect(""): store an empty Location value instead of a null one #37893 bumps the print size snapshot. Both hunks conflict trivially with this.

Background

  • JSC checks the stack limit in the prologue of every JS function. When a call fails that check it throws RangeError: Maximum call stack size exceeded. into the caller, which is left intact and can catch it. Calls from JS into host (native) functions such as the Response constructor and Response.prototype.text do not perform that check; they run in the stack space JSC keeps in reserve below the JS limit. That is why the deepest frame can still create a Response after catching the overflow, and why doing it there exercises "native promise-returning method called with the JS stack exhausted", the condition Fix unhandled exception in JSC__JSPromise__wrap when resolving promise #23961 was about.
  • JSPromise.wrap (src/jsc/JSPromise.rs, backed by JSC__JSPromise__wrap in src/jsc/bindings/bindings.cpp) is the path text() takes on an empty body: it runs the native body conversion and wraps the result in a promise. Fix unhandled exception in JSC__JSPromise__wrap when resolving promise #23961 made it convert an exception raised while creating that promise into a rejected promise instead of leaving it pending.
Measurements (debug build in this container)

bun bd test test/js/web/fetch/response.test.ts -t "handle stack overflow" on main: 7560ms, reported as this test timed out after 5000ms. The report that prompted this saw 8029ms, 7655ms and 5081ms on another machine. The recursion alone, run as a script with the debug binary: 7288ms, 5254ms, 4936ms.

Cost of different test shapes, same debug binary:

shape                                              debug     release
Response + text() in every frame (main)            5231 ms    16 ms
try/finally in every frame, 32 allocations         1518 ms     7 ms
catch in deepest frame, 32 allocations (this PR)     44 ms     5 ms
plain recursion, no allocations                     278 ms     4 ms

…frame

"handle stack overflow" created a Response and called text() in every one
of the ~26k frames it took to exhaust the stack. On a debug build that is
5 to 8 seconds, past the default 5 second per-test timeout, so the file
fails on main under `bun bd test`. Recurse with empty frames instead and
only create Responses from the deepest frames once the stack has run out
(about 40ms on a debug build), and assert on what those calls return.

"print size" snapshotted the byte size of this test file, so the snapshot
had to change with every edit to the file. Inspect a fixture with a fixed
size instead.
@coderabbitai

coderabbitai Bot commented Aug 12, 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: 10 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: dfd15186-2bf4-4288-81af-4c6552d1c680

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and e3e5fff.

📒 Files selected for processing (1)
  • test/js/web/fetch/response.test.ts

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed, waiting on CI.

  • Reproduced on main (9a543cc) with bun bd test test/js/web/fetch/response.test.ts -t "handle stack overflow": 7560ms, this test timed out after 5000ms. Passes in ~365ms on the release binary, which is why CI never saw it.
  • With this change the test takes 36 to 51ms on the debug build; the whole file passes 23/23 (10 debug runs, 30 release runs).

@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.

LGTM — test-only refactor that speeds up a debug-build timeout and stops a snapshot from churning on every edit.

What was reviewed:

  • handle stack overflow: confirmed the deepest surviving frame still calls .text() with the JS stack at its limit (host calls skip the prologue check), so the #23961 condition is preserved; exactly-one-overflow invariant holds because every parent's recurse() returns normally after the deepest catch.
  • New assertions are strictly stronger — the old test never awaited the promises; a pending/rejected .text() now fails via Promise.all.
  • print size: tempDir + normalizeBunSnapshot(_, dir) matches the harness convention and the <dir> substitution path in test/harness.ts:2131; also fixes import.meta.dir being passed to expect() instead of the normalizer.
Extended reasoning...

Overview

This PR touches a single test file, test/js/web/fetch/response.test.ts, with two independent improvements and no production code changes:

  1. handle stack overflow is rewritten to reach stack exhaustion via cheap empty-frame recursion, catch the RangeError in the deepest frame, and call new Response().text() only from the 32 deepest frames while unwinding — instead of allocating a Response and awaiting .text() in every one of ~26k frames. This drops debug-build runtime from 5–8s (which trips the default 5s timeout under bun bd test) to ~40ms, and adds an assertion the old test lacked: that all 32 .text() promises actually resolve to "".
  2. print size now inspects a fixed-size 18-byte fixture in a tempDir rather than import.meta.filename, so the inline snapshot no longer encodes this file's own byte count and won't need bumping on every edit. It also fixes a latent bug where import.meta.dir was the second argument to expect() (a no-op custom message) rather than to normalizeBunSnapshot.

Security risks

None. Test-only change; no runtime, network, filesystem-permission, or auth surface is affected. The tempDir helper is the harness-standard, self-cleaning temp directory (using), so no leftover state.

Level of scrutiny

Low-to-moderate. The only real question is whether the rewritten stack-overflow test still exercises the #23961 regression path. I traced the mechanism: JSC's stack check fires at the JS function prologue, so the frame whose recursive call just failed is by construction at the JS stack limit; new Response() and .text() are host calls that skip that check and run in JSC's reserve zone, so they execute in exactly the near-exhausted state #23961 cared about. Because every frame wraps its recursive call in try/catch, only the deepest frame's catch fires (all shallower recurse() calls return normally), making overflows.length === 1 deterministic. The author validated this across JIT on/off, ulimit -s 1024/unlimited, and 40 runs — well beyond what REVIEW.md asks for de-flaking work.

The new test is strictly stronger than the old one: previously it only asserted that something threw the overflow message (the recursion itself would, regardless of what .text() did) and never inspected the returned promises. Now a pending promise (the pre-#23961 symptom) hangs Promise.all and times out; a rejected one fails the equality; a leaked pending exception would show up as a second entry in overflows.

Other factors

  • Follows harness conventions verbatim (tempDir with using, normalizeBunSnapshot(str, dir) → <dir> per test/harness.ts:2131-2135), matching the CLAUDE.md example pattern.
  • No CODEOWNERS entry covers this path.
  • The PR description is unusually thorough — it explains the JSC stack-check mechanism, cites the exact JSPromise.wrap path, provides a shape-vs-runtime table, and flags the two conflicting PRs (#32044, #37893). The claim that the old print size snapshot had to be bumped on 6/6 prior edits is consistent with it encoding the file's own byte size.
  • No prior reviews or unaddressed comments; only a CodeRabbit rate-limit notice in the timeline.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:06 PM PT - Aug 12th, 2026

❌ @robobun, your commit e3e5fff has 2 failures in Build #93479 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37910

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

bun-37910 --bun

This branch has not been deployed

No deployments
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