Skip to content

test(fetch): speed up fetch.test.ts (~30s to ~12s release) and tighten its assertions - #38470

Open
robobun wants to merge 3 commits into
mainfrom
farm/b48a1736/speed-up-fetch-test
Open

test(fetch): speed up fetch.test.ts (~30s to ~12s release) and tighten its assertions#38470
robobun wants to merge 3 commits into
mainfrom
farm/b48a1736/speed-up-fetch-test

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/web/fetch/fetch.test.ts is a serial-phase file that takes ~29s on every CI lane (41s on debian x64 ASAN), and a release build is barely faster than a debug one, so the time is spent waiting rather than running.
  • Locally the time splits into: a fixed 10s server hold in the explicit timeout idle test, 6-8s in the absolute-deadline idle test, ~7s of Bun.gc(true) calls in testBlobInterface (two forced GCs per compared byte, ~6,300 calls in total), and ~4s of independent RSS cases in fetch should allow duplex running one after another.
  • The testBlobInterface GC loops are synchronous and run inside describe.concurrent("Bun.file"), so they also stall the HTTPS cases that share that concurrent batch (on a slow machine those time out).
  • A few cases nearby are weak: #3545 fires an unawaited fetch() at example.com and only asserts that nothing throws synchronously, the 100-continue tests listen on a hardcoded port 8080, and the explicit timeout test asserts toStartWith("ERR:") on what is actually the DOMException legacy code (ERR:23).

Fix

  • Explicit timeout idle test (10.0s to 4.0s): the child's server holds every request until the control request (no timeout) has been aborted by the 1s default, instead of for a fixed 10s. The three explicit requests are sent first and the control only once the server has seen them, so their idle timers are armed no later than the control's: a build that ignored the explicit timeout would have aborted them by the sweep that aborts the control, before the server releases anything, so the check stays deterministic. The control fires at the first sweep tick, ~4s in.
  • Absolute-deadline idle test: /b now drips body bytes until /h has timed out and is only then completed. Before, /b completed after a fixed 6s, inside the 4-8s window in which /h's deadline fires, so a body path that stopped re-arming was only caught when the sweep phase happened to fall under 6s; now /b has to survive the very sweep that killed /h. /b is sent first and /h only after the server has /b's request, for the same ordering argument as above. Its duration is /h's deadline: two sweep ticks, which is ~8s in practice because this test's own sockets are what start the sweep timer (4-8s only when earlier sockets left it running).
  • Both idle tests are it.concurrent, so together they cost ~8s instead of 16-18s. Because it.concurrent(name, fn, timeout) is not one of prettier's test-call shapes, prettier wraps both calls and re-indents their bodies; git diff -w shows the real changes.
  • testBlobInterface (~7s to ~0.2s in release): the arrayBuffer / bytes / arrayBuffer -> arrayBuffer / arrayBuffer -> bytes cases compare the whole array with toEqual after one GC instead of calling gc() before and after every byte; that is the same check (the buffer is read after a full GC), and the bytes() cases additionally assert the result is a Uint8Array. withoutAggressiveGC had no other users. This is where most of the drop in the expect() count (7,438 to ~1,000) comes from.
  • The four RSS cases at the end of fetch should allow duplex are it.concurrent (4.2s to 2.0s); they each spawn their own child or servers. One name is shortened by a few characters (... is backpressured to ... is stalled, matching its sibling) so that prettier keeps the call on one line instead of re-indenting the test. Their RSS observation windows are unchanged.
  • should allow very long redirect URLS runs its round trip twice instead of 100 times (each round is two requests plus a forced GC in the handler; 5s+ in this container's debug build, 0.3s now). The loop dates from fix(fetch) redirects bodies should be handled #8874, when a redirect response's body was drained and its connection pooled; since fetch: follow redirects on the response head instead of awaiting the 3xx body #33613 a 3xx that carries a body has its connection closed instead (src/http/lib.rs, the is_redirect_pending branch of the body-length check), so repeating the round trip cannot surface an undrained redirect body. What a second round does add is the next /redirect riding the connection pooled by the previous round's final response (checked with a raw server: two rounds use three connections, and the second connection serves round 1's final response and then round 2's /redirect); the comment now says exactly that, and which redirect responses get pooled is already pinned by connection count in fetch-keepalive.test.ts ("302 carrying a body" and friends). The test also asserts redirected and the final body now.
  • Assertions: the idle tests assert the exact JSON the child prints (including TimeoutError: The operation timed out. for the control) and an empty stderr; the Response life cycle test pipes both children and asserts the client's stderr is empty, that it logged all 10 iterations, and that the server printed nothing; #3545 fetches http://host:port?a=b from a local server and asserts the request target it received (/ + ?a=b), response.url and the status; the invalid header test asserts a TypeError with Invalid header name: ... (on current main fetch() returns a rejected promise here rather than throwing; older releases threw); the 100-continue tests listen on port 0.
  • Both rewritten idle tests were checked against the regressions they guard by simulating those from the test side (the explicit requests sent without a timeout, which is what a client that ignored the option would do; /b's body bytes withheld, which is what the client sees if body reads stop re-arming). On release and debug builds both tests then fail through their toEqual diff: the three explicit requests come back TimeoutError at the same ~4s sweep as the control, and /b is already a TimeoutError when /h fails (output in the details below). The second commit makes a /b body timeout land in that diff instead of being thrown out of the test (fetch() resolves on the head, so it surfaces from text()), and only lets the test complete /b's body.
  • Verified: bun bd test test/js/web/fetch/fetch.test.ts (debug + ASAN, same binary) 225.5s before, 41.0s after; USE_SYSTEM_BUN=1 bun test test/js/web/fetch/fetch.test.ts (release) 29.9s before, 12.3s after. On this PR's CI run (build 95959) the file took 12.1-12.2s on the linux release lanes and 12.5s on debian x64 ASAN, against the 28-30s / 41s it took on the builds quoted above. The set of failing cases in this container is identical before and after (see details); everything this PR touches passes on both builds.
  • What is left (release): ~8s is the absolute-deadline test's two-tick deadline (the explicit timeout test runs inside it), ~2s the duplex RSS windows, ~0.8s the Response life cycle fixtures. The 8s is a floor set by uSockets' fixed 4s sweep; the two idle tests are written so they do not depend on the tick length, so a test-only override of the sweep period in bun-usockets (serve.test.ts and fetch-http2-client.test.ts pay the same floor) would be the next step and would let these two tests shed their ordering comments. Not attempted here.
  • Not touched: the test rewritten by test: remove the timer race from the fetch redirect + Connection: close test #37913, and none of the hunks here are adjacent to the insertion points of the other open PRs against this file (the seven currently mergeable ones still merge cleanly on top of this branch).

Background

  • Idle timeouts on the fetch client are uSockets socket timeouts. uSockets sweeps sockets every 4s (LIBUS_TIMEOUT_GRANULARITY), and us_socket_timeout(s, seconds) arms the socket for ceil(seconds / 4) sweeps, so a 1s timeout fires at the next sweep (0-4s later) and a 5s timeout at the second one (4-8s later). The sweep timer only runs while the HTTP thread's loop has sockets and starts when the first one is created, which is why the child in the explicit timeout test fires at almost exactly 4s, why the absolute-deadline test usually lands at 8s, and why that test needs a 2-sweep timeout at all: a 1-sweep timeout fires at the next sweep whether or not it was re-armed in between.
  • BUN_CONFIG_HTTP_IDLE_TIMEOUT sets the default idle timeout (seconds) when the HTTP thread starts, which is why that test runs in a child process; fetch(url, { timeout: ms }) overrides it per request.
  • it.concurrent cases run together with the it.concurrent cases declared directly before and after them; a plain it in between is a barrier. That is why only the adjacent groups above were converted, and why the two idle tests had to both become concurrent to overlap.
Local failure set (unchanged by this PR)

In this container 25 cases fail identically before and after: localhost resolves to ::1 first here, so listeners bound to the name localhost are unreachable from the client (the situation #37442 addresses), and the bad permissions throws cases do not apply when running as root. Before this change the debug run additionally timed out in the 12 utf16 ... (with gc) cases, in the HTTPS cases that share their concurrent batch, and in the long redirect test; those all pass now.

Simulated regressions against the rewritten idle tests (release build; the debug build fails the same way). Explicit requests sent without a timeout:

-   "withInfinity": "hello",
-   "withTimeout": "hello",
-   "withZero": "hello",
+   "withInfinity": "TimeoutError: The operation timed out.",
+   "withTimeout": "TimeoutError: The operation timed out.",
+   "withZero": "TimeoutError: The operation timed out.",
(fail) an explicit numeric `timeout` extends the socket idle deadline past the default [4014.19ms]

/b's body bytes withheld (what the client sees if body reads stop re-arming the timer); /b had already failed at the same sweep as /h (both at 8007ms in a standalone process, where the sweep starts with the first socket):

-     "body": "body bytes that trickle in one at a time",
-     "ok": true,
-     "status": 200,
+     "message": "The operation timed out.",
+     "name": "TimeoutError",
+     "ok": false,
(fail) the idle timer is an absolute deadline for the response header block (not re-armed by a byte drip) [8009.14ms]

Release per-case profile before: explicit timeout test 10.0s, absolute-deadline test 6.0s, bounds memory when the upload target is slower 2.0s, suspends a type:'direct' body 1.5s, Bun.file concurrent batch ~1.75s (dominated by its GC loops), Response/Request/Blob utf16 ... (with gc) cases 0.3-0.6s each (16 cases), should not keep Response alive 0.65s, bounds memory when a handler forwards 0.63s.

Release build of this file goes from ~30s to ~12s, debug+ASAN in this
container from 225s to 41s. Where the time went and what changed:

- explicit-timeout idle test held every request for a fixed 10s; the
  server now holds them until the control request has been aborted by
  the 1s default, and the control is only sent once the explicit
  requests have been seen, so the check stays deterministic.
- absolute-deadline idle test: /b now drips its body until /h has timed
  out and is completed afterwards, so the body re-arm check no longer
  depends on the sweep phase. Both idle tests run concurrently.
- testBlobInterface compared buffers one byte at a time with a forced GC
  before and after every byte (~6300 Bun.gc(true) calls); compare the
  whole array after a GC instead.
- the four RSS cases in "fetch should allow duplex" run concurrently.
- very long redirect URL test runs 20 iterations instead of 100 and also
  asserts the redirected flag and the final body.

Assertions: the idle tests assert the exact child output and an empty
stderr, the Response life cycle test pipes and asserts both children's
output, #3545 uses a local server and checks the request target instead
of fetching example.com, the invalid header test asserts the TypeError
and its message, and the 100-continue tests listen on port 0 instead of
a hardcoded 8080.
@coderabbitai

coderabbitai Bot commented Aug 14, 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: 1 minute

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: 2e2a1bfe-fdba-45c6-9474-937ce6489bf5

📥 Commits

Reviewing files that changed from the base of the PR and between 032b8db and bf6aa70.

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

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 14th, 2026

@robobun, your commit c4ccb03 has 1 failures in Build #95959 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38470

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

bun-38470 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI for the current head (bf6aa70, build 96302) passed all 177 jobs that ran; the only things left in that build are the two darwin jobs sitting in the agent queue, as in the previous build. The remaining annotations are retried flakes in unrelated files.

Measured locally on the same binaries before and after this change: bun bd test test/js/web/fetch/fetch.test.ts (debug + ASAN) 225.5s to 41.0s, USE_SYSTEM_BUN=1 bun test test/js/web/fetch/fetch.test.ts (release) 29.9s to 12.3s, with an identical set of passing and failing cases in this container before and after. On CI (build 95959) the file took 12.1-12.2s on the linux release lanes and 12.5s on debian x64 ASAN, down from 28-30s and 41s. The PR body lists the cases that accounted for the time and what was done to each, and includes the simulated-regression output for the two rewritten idle tests.

bf6aa70 came out of a self-review: the long-redirect test's loop comment cited a pooling path that no longer exists (a 302 with a body has its connection closed since #33613), so the test now runs two rounds with a comment describing the reuse the second round actually exercises. The seven currently mergeable PRs that also touch this file (#37436, #37913, #35988, #37425, #34896, #38043, #29198) still merge cleanly on top of this branch.

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

I reviewed this PR and didn't find any bugs. The mechanical parts (port 0 for the 100-continue tests, toEqual in place of the per-byte GC loops, local server for #3545, it.concurrent on the isolated duplex RSS cases, piping the life-cycle fixtures) are straightforward improvements. The two idle-timeout test redesigns are the substantive part — they replace fixed holds with event-driven release keyed to the uSockets 4s sweep and rely on an ordering argument about when each socket's timer is armed relative to the control. The reasoning in the description is thorough and I couldn't find a hole in it, but it's subtle enough that a human familiar with the fetch client's idle-timeout path should confirm the rewritten tests still guard the same regressions.

Checked: the four duplex it.concurrent cases each spawn their own subprocess/servers with no shared state; the two idle tests are adjacent (so they actually overlap) and don't share HTTP-thread state (one runs in a child with BUN_CONFIG_HTTP_IDLE_TIMEOUT, the other uses an explicit per-request timeout in the test process); withoutAggressiveGC is only removed from this file's imports, not from harness; the 100→20 redirect loop still exercises pooled keep-alive reuse and now asserts redirected and the body.

Extended reasoning...

Overview

Test-only change to test/js/web/fetch/fetch.test.ts that speeds the file from ~30s to ~12s (release) and tightens several assertions. No production code is touched. The changes fall into three buckets:

  1. Mechanical tightening — replace per-byte gc()+compare loops in testBlobInterface with a single toEqual after one GC (plus toBeInstanceOf(Uint8Array) for bytes()); switch the two 100-continue tests from hardcoded port 8080 to port 0; rewrite #3545 to hit a local Bun.serve and assert the parsed request target instead of firing an unawaited fetch at example.com; rewrite the invalid-header test to assert TypeError + the specific message; pipe and assert stdout/stderr for both children in the Response life-cycle test.
  2. Concurrency — mark the four duplex RSS cases and the two idle-timeout tests it.concurrent. Each of these spawns its own subprocess or servers with port: 0 and shares no module-level state, so they are safe to overlap.
  3. Timing-test redesign — the explicit-timeout test's server now holds requests until the no-timeout control has been aborted (instead of a fixed 10s), and the absolute-deadline test now drips /b's body until /h has timed out (instead of completing /b at a fixed 6s that fell inside the 4–8s sweep window). Both changes are argued to be strictly tighter than before.

Security risks

None. Test file only; no auth, crypto, or user-input handling.

Level of scrutiny

Bucket 1 is low-risk and follows repo test conventions (never contact external hosts; port 0; assert exact error class/message; toEqual over many toBe). Bucket 2 is low-risk given each case is isolated. Bucket 3 is the part that warrants a human look: the redesigned idle-timeout tests depend on specific reasoning about uSockets' LIBUS_TIMEOUT_GRANULARITY sweep and the relative arming order of client-side idle timers. The argument is well-stated (the explicit requests / /b are sent first and only after the server has seen them is the control / /h sent, so the control's timer cannot be armed earlier), and I could not find a flaw, but this is exactly the kind of subtle timing invariant that should be confirmed by someone who owns the fetch idle-timeout implementation — a mistake here would silently drop regression coverage rather than fail loudly.

Other factors

  • The redirect-URL loop is reduced 100→20 with a comment naming what it guards (#8874, undrained redirect body poisoning the pooled connection); 20 sequential iterations still exercise keep-alive reuse and the test now additionally asserts redirected and the body, so net coverage is not obviously reduced.
  • One candidate issue was raised and refuted by verifiers: whether fetch() with an invalid header throws synchronously (which would make the new const promise = fetch(...) line throw before the .rejects assertion). The verifier concluded it rejects rather than throws; the author reports the test passes on both debug and release.
  • The withoutAggressiveGC import removal is scoped to this file; the helper remains in harness.ts and is used elsewhere.
  • The Response life-cycle test now pipes the server child's stdout/stderr but only drains them after kill(); the assertion is that both are empty, so there's no realistic pipe-buffer risk.

fetch() resolves once the response head is in, so a timeout on /b's body
surfaces from text() and was thrown out of the test instead of showing up
in the hdr/bod comparison. Also only let the test end /b's body: running
out of drip bytes no longer completes the response by itself.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the ordering argument the idle tests now rest on, two pointers plus a check, for whoever looks at the idle-timeout path:

  • The arming points are documented on IDLE_TIMEOUT_SECONDS in src/http/lib.rs: the timer is armed in on_open, re-armed on writes and on body-phase reads, and not on response-header reads. The sweep granularity is us_socket_timeout in packages/bun-usockets/src/socket.c ((seconds + 3) >> 2 sweeps of 4s each, us_internal_timer_sweep in loop.c), which is where the "1s fires at the next sweep, 5s at the second one" numbers in the tests come from.
  • Both tests were run against the regressions they guard, simulated from the test side: the explicit requests sent without a timeout (a client that ignored the option), and /b's body bytes withheld (what the client sees if body reads stop re-arming). Both tests fail on release and debug builds, through their toEqual diff; the output is in the PR description. In the second case /b had already been rejected at the same sweep as /h when /h's failure was observed, which is the property the /b leg relies on.

The second commit came out of that check: fetch() resolves once the head is in, so a /b body timeout surfaced from text() and was thrown out of the test rather than landing in the comparison (it still failed, just less readably). settle now uses catch(), and only the test completes /b's body; running out of drip bytes no longer completes it on its own.

Comment thread test/js/web/fetch/fetch.test.ts
A 302 that carries a body has its connection closed rather than drained
and pooled, so repeating the round trip cannot surface an undrained
redirect body; the only reuse the loop adds is the second round's
/redirect riding the connection pooled by the first round's final
response. Run it twice and say so, and point at fetch-keepalive.test.ts
for the redirect pooling matrix.

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

I re-reviewed after the reply on the invalid-header test — that finding was wrong (it was checked against 1.3.14; reject_on_exception in src/runtime/webcore/fetch.rs confirms current main returns a rejected promise, and the case passes on CI). No bugs found this run.

Given the two idle-timeout tests are rewritten around a specific uSockets sweep-ordering argument, and a couple of hunks trade coverage for speed (redirect loop 100→2, per-byte-GC compare → single toEqual), a human sign-off on those judgment calls would still be worthwhile.

Checked this run: withoutAggressiveGC is only the import here (helper still used by 15+ other files); the four it.concurrent duplex cases each own their subprocess/servers so no shared state; port 8080→0 and the local-server #3545 rewrite remove the last external-host / fixed-port cases in the touched hunks.

Extended reasoning...

Overview

Test-only change to test/js/web/fetch/fetch.test.ts (~200 lines): replaces per-byte GC-in-a-loop comparisons in testBlobInterface with a single toEqual; rewrites the two fetch idle-timeout tests to release held responses on an observed event (the control request timing out / /h failing) instead of fixed sleeps; converts six independent cases to it.concurrent; drops the long-redirect loop from 100 to 2 iterations; moves #3545 off example.com onto a local server; tightens the invalid-header assertion to the exact TypeError message via .rejects; switches the two 100-continue tests from port 8080 to port 0; pipes and asserts child stdout/stderr in the Response-life-cycle test. No production code touched.

Prior finding withdrawn

My earlier inline comment claimed fetch() throws synchronously on an invalid header. That was checked against Bun 1.3.14. On current main the fetch entry point is wrapped by reject_on_exception (src/runtime/webcore/fetch.rs:341-368, comment: "fetch() never throws synchronously"), so the call returns an already-rejected promise and the .rejects assertions run. The author's reply and CI build 95959 (fetch.test.ts green on every lane) confirm this; the thread is resolved and I'm not re-raising it.

Security risks

None. Test-only; the only network endpoints introduced are local Bun.serve({ port: 0 }) / net.createServer on 127.0.0.1, replacing prior example.com and hardcoded-8080 uses.

Level of scrutiny

Medium. It's test-only and CI-green, but the two idle-timeout rewrites rest on a non-trivial ordering argument about uSockets' 4s sweep granularity (the explicit requests' timers are armed no later than the control's, so a regressed build would abort them by the same sweep). The author simulated both regressions and showed the tests fail through their toEqual diff, which is convincing, but this is timing-adjacent logic a maintainer familiar with src/http idle-timeout arming should confirm.

Other factors

Two hunks reduce what the original tests exercised: the redirect loop drops from 100 iterations (original comment: "run it more times to check Malformed_HTTP_Response errors") to 2, with a new rationale that the pooled-connection-after-redirect property is covered in two rounds and that connection counts are pinned separately in fetch-keepalive.test.ts; and the testBlobInterface per-byte compare with a forced GC between every read becomes one toEqual after one GC. Both are reasonable trade-offs for a ~5.5× debug-build speedup, but they're coverage decisions rather than mechanical refactors, so deferring for a human to confirm they're acceptable. The PR body says "20 iterations" for the redirect loop while the code (and its comment) say 2 — the code's comment is self-consistent, so this reads as the description not being updated after commit bf6aa70, not a code bug.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

The description was updated together with bf6aa70; it now describes the two-round loop (and why two rounds are what the test can exercise). The two places where this trades repetition for a single check, that loop and the whole-array compare in testBlobInterface, are each spelled out in the Fix section for whoever reviews, along with the simulated-regression runs for the two idle tests.

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