Skip to content

test: speed up fetch-backpressure.test.ts and tighten its assertions - #40965

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/9f51ead6/fetch-backpressure-test-speed
Aug 30, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/9f51ead6/fetch-backpressure-test-speed

Conversation

@robobun

@robobun robobun commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/web/fetch/fetch-backpressure.test.ts takes about 12 s per CI lane (build #108487: 11.75 s debian 13 x64, 11.2 s asan, 10.3 s windows aarch64). Locally: 6.2 s release, 60 s debug.
  • Four tests drain a 1 GiB body each (3.4 s release here, 30 s debug). The rest is fixed waits (a 60 ms RSS settle in 21 spawned clients, 2 ms timers, a 50 ms sleep) and three describe blocks that ran one test at a time.

Fix

  • The 1 GiB tests read until the server has written 1 MiB past its stall point, then cancel. That proves the pause and the resume. The 16 MiB tests still drain a paused body to the end, per framing, and check its md5.
  • Fixed sleeps become conditions: settled() (write() returned false or the body is out, then sent() holds still), wrote(n), closed, and a stdout/stdin handshake with the spawned clients.
  • Stronger assertions: md5 of every drained body, an all-"A" check on streamed chunks, sent() === wire, stderr === "" for children, exact error name, code and message for reset and abort.
  • Verified: release 6.22 s to 2.45-2.69 s (5 runs), debug 60.5 s to 17.2-18.0 s (5 runs, plus 3 at --max-concurrency=5). No test is deleted or skipped.

Background

  • Receive backpressure: when the consumer of a fetch body stops reading, the HTTP thread stops reading the socket once 256 KiB is staged (BODY_HIGH_WATER_MARK, src/http/Signals.rs). The kernel buffers fill, then the server's write() returns false. That is the observable pause.
  • Framing (content-length, chunked, close-delimited) does not enter the pause/resume path. It only decides how the end of the body is detected, which the 16 MiB tests exercise.
  • settled() has to sample: no event reports "nothing more is coming". It starts only after the socket refused a write (or the body is out), so it waits for the absence of progress, not for a fixed time.
Notes
  • The runner executes consecutive concurrent tests as one group (20 at a time, 5 under ASAN). The three describe blocks that were sequential held 21 tests, run one at a time. They run concurrently now. The two tests that count pooled connections stay serial, at the end of the file: a connection returns to the pool only once the HTTP thread has read the last of its body, and a busy sibling could turn that into an extra connection.
  • Stall points observed here: h1 kinds block after 44-86 chunks of 64 KiB, h3 after 97, h2 only at the full 16 MiB (its 16 MiB initial window).
  • gzip: gzipSync(randomBytes(16 MiB)) cost 1.9 s on the test's main thread in a debug build (365 ms release). Stored blocks over the "A" body keep the wire size equal to the body (16779789 bytes) and cost 0.2 s. The decoder still runs over every byte.
  • The h3 cases time out at 5 s in this slow debug container, before and after this change (before: all four in every run, after: zero to two per run). test: serve the h3 receive-backpressure bodies from an idle process #37445 addresses that by serving h3 from an idle process. This PR does not touch that.
  • The remaining wall time on linux release is the download-proxy RSS test (2.2 s, 40 x 32 MB). Its parameters are unchanged: its threshold was tuned against the unfixed build.
  • Observation, not changed here: in release builds, after earlier fetch() traffic in the same process, a Bun.write(file, res) whose body is still arriving keeps the Response reachable for about 300 event-loop turns (0.4-0.9 s), and the abort of a collected S3 stream takes about as long to reach the origin. Any socket event on the JS loop releases it at once. Waking the HTTP thread alone does not. Debug builds do not show it. The two collection tests here spend that time in their GC loops.
  • Ran the file with the system bun as well (USE_SYSTEM_BUN=1), 5 runs, all green.

no test proof · iteration 3 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch-backpressure.test.ts

The four "server stops writing" tests each drained a 1 GiB body after the
pause. They now read until the server has written 1 MiB past the point
where it stalled, which proves the resume, and cancel the rest. The exact
drain of a body that paused is still covered by the 16 MiB tests for each
framing.

Fixed waits become conditions: a server exposes `settled()` (its write()
returned false or the body is out, then sent() holds still), `wrote(n)`
and `closed`. The spawned clients report "stalled" on stdout and wait for
"go" on stdin instead of sampling RSS for 60 ms. The small-unread-bodies
origin holds the last part until the Response exists instead of 2 ms
timers. The released-reader client waits for "go" instead of 50 ms.

Assertions: every drained body is checked by md5 against the known digest
and every streamed chunk is checked to be all "A". The server's sent()
must equal the wire size. Fixture children assert stderr === "". The
peer-reset tests assert the error name, code and message. The S3 error
and AbortError tests assert the message.

The previously sequential describes run concurrently. The two tests that
count pooled connections stay serial, at the end of the file.

The gzip variant uses stored blocks over a body of "A": the wire still
carries about as many bytes as the body, and a debug build no longer
spends ~2 s compressing 16 MiB of random bytes on the test's main thread.
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 21 days. After that, they cost $0.25 per reviewed file.

Or wait 8 minutes for your next included review.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 90ae89c3-2a19-4987-ae88-21a252728d34

📥 Commits

Reviewing files that changed from the base of the PR and between 41906a4 and 6814474.

📒 Files selected for processing (1)
  • test/js/web/fetch/fetch-backpressure.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.

I reviewed this PR and didn't find any bugs. Because it's a large refactor of a timing-sensitive test file — replacing RSS sampling with an event-then-sample settled() protocol, and swapping the 1 GiB full-drain assertions for cancel-after-resume — a human look would still be worthwhile to confirm the coverage trade-offs and flake margins hold across CI lanes.

What was reviewed:

  • The new progress()/settled() machinery: settled() awaits blocked/finished before its 3×10 ms stability samples, so it's a bounded poll after an event, not a time-as-condition wait; wrote(n)/closed are wired on every path that uses them (h3 never awaits closed).
  • spawnClient's stdin/stdout handshake: stderr is drained concurrently via .text() while forEachLine iterates stdout, so no pipe deadlock; result parsing guards on empty output.
  • Assertion strength: md5 digest + isAllA() + exact error name/code/message replace length-only checks; .toEqual composites put stderr/stdout before exitCode per convention; no test is deleted or newly skipped.
  • Remaining timeouts (20_000 on the idle-process race, pre-existing 30_000 on the RSS test) sit above internal deadlines rather than acting as the wait condition.
Extended reasoning...

Overview

This PR refactors test/js/web/fetch/fetch-backpressure.test.ts (+483/−356) to speed it up (reported 6.2 s → ~2.5 s release, 60 s → ~18 s debug) and tighten assertions. It introduces shared module-level constants (PAYLOAD, DIGEST, BIG/BODY), a progress() helper exposing sent()/settled()/wrote(n)/closed across all transport kinds (h1/h1-chunked/h1-gzip/h1-tls/h2/h3), an md5-based body-integrity check, an isAllA() memcmp helper, and a stdin/stdout stall()/"go" handshake between the test and spawned clients that replaces per-client RSS sampling. The four 1 GiB drain tests now use readUntilResumed() (read until the server has written 1 MiB past its stall point, then cancel) instead of draining the full body; the 16 MiB tests still drain to completion and now assert an md5 digest. Several describe blocks move to describe.concurrent, per-test 60 s timeouts are dropped, gzip bodies switch from randomBytes to level-0 stored blocks, error assertions gain name/code/message, and the two connection-pool-counting tests are relocated into a trailing describe.serial with the "small unread bodies" test rewritten to gate its last packet on a resolver instead of setTimeout.

Security risks

None. This is a test-only change with no production code touched. All servers bind port: 0 on loopback; no external network contact; subprocess -e scripts are constructed from JSON.stringify'd local URLs and static template literals.

Level of scrutiny

Moderate-to-high. Although test-only, this file is the primary regression guard for fetch receive-side backpressure across six transport kinds, and the refactor changes how the pause is observed (from client-side RSS sampling to a server-side "write returned false, then sent() held still" sample) and how much of the resumed body is verified on the 1 GiB paths. Per REVIEW.md's "Never silently weaken an existing test", the 1 GiB → cancel-after-resume swap deserves a human sanity check that the property the original assertion protected (resume after a real kernel-buffer stall delivers the body without deadlock) is still covered — the PR argues it is, via the 16 MiB digest-checked drains plus readUntilResumed's "server wrote 1 MiB more" condition, and I agree the reasoning holds, but it's a judgment call worth a maintainer's eye. The settled() helper's 3×10 ms stability window after blocked.resolve() is a bounded poll gated on an event (write() returned false past 8 chunks, or the whole body is out), which fits the "poll a bounded window for absence" allowance rather than the forbidden "sleep-then-assert" pattern.

Other factors

The change aligns well with test/CLAUDE.md and REVIEW.md conventions: .toEqual on composite {stdout, stderr, exitCode} objects with exitCode asserted last, Buffer.alloc(n, fill) over .repeat(), bunExe()/bunEnv with -e, await using on spawn/serve/listen, tempDir from harness, forEachLine for line-framed stdout, and describe.concurrent for independent subprocess tests. The describe.serial block's justification comment (a busy sibling could delay a body's last packet reaching the HTTP thread and inflate the pooled-connection count) is plausible; whether placing it at end-of-file is sufficient isolation from the concurrent describes above it depends on Bun's runner scheduling that a maintainer will know offhand. The two remaining explicit per-test timeouts (20 s on the idle-process hang guard, pre-existing 30 s on the release-only RSS test) are ceilings over internal 10 s / multi-second workloads rather than wait conditions. No CODEOWNERS entry covers this path. Given the size, the flake-sensitivity of the domain, and the coverage trade-off on the 1 GiB paths, deferring for a human confirmation is the right call even with zero findings.

@robobun

robobun commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

Two points from the review, for whoever picks this up:

Serial block at the end of the file. The runner builds its schedule from declaration order: consecutive concurrent tests form one group, and a serial test is a group of its own (the layout is described at the top of src/runtime/test_runner/Execution.rs). Groups run one after another. So the three serial tests start only after every concurrent test in the file has finished. Nothing else runs next to them.

The 1 GiB drains. What those four tests still prove is the same pause: the server's write() returns false and sent() holds still below the declared length while the reader is stalled, on every host, because 1 GiB exceeds any loopback buffer. The resume is now proven by the server writing 1 MiB past its stall point, which needs the client to read from the socket again. What they no longer do is read the remaining 1 GiB to done. The end of a body after a pause is asserted, with an md5 of the whole body, by the 16 MiB stalled ... drains the full body tests for every framing, and in-process by two sequential keep-alive responses each drain fully and the two then ... drains the whole body tests. The pause itself does not depend on framing (src/http/lib.rs, maybe_pause_receive and resume_receive never look at content-length or transfer-encoding), only the end-of-body detection does, and that is what the smaller bodies exercise.

Local numbers: release 6.22 s to 2.45-2.69 s over 5 runs, debug 60.5 s to 17.2-18.0 s over 5 runs plus 3 at --max-concurrency=5. The h3 cases time out in this slow debug container before and after the change. #37445 addresses that separately.

@robobun

robobun commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:32 AM PT - Aug 30th, 2026

✅ @robobun, your commit 68144749b4427c946943a4bd2536001f957e57f8 passed in Build #108542! 🎉


🧪   To try this PR locally:

bunx bun-pr 40965

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

bun-40965 --bun

@Jarred-Sumner
Jarred-Sumner merged commit c03b038 into main Aug 30, 2026
6 of 7 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/9f51ead6/fetch-backpressure-test-speed branch August 30, 2026 22:32
Jarred-Sumner added a commit that referenced this pull request Sep 14, 2026
…ix two CI test deadlocks (#42665)

### What does this PR do?

Three of the tests that have been turning builds red over the last few
days, by root cause. (The fourth and largest, `spawn-pipe-leak.test.ts`,
was #42428 and is already fixed on main by #42571.)

**1. `Bun.build` left all but one bundler `Worker` alive after the
build** (`bun-build-api.test.ts` "bytecode: repeated builds don't retain
the generated code", flaky in 129 of 198 builds on 09-13)

- At the end of a build each pool thread gets an idle task that tears
down its `Worker` (its AST heap, its transpiler), and
`wake_for_idle_events()` wakes the pool. It woke every parked thread but
posted one notification. One thread consumed it, left `Event::wait` and
drained its idle queue. The rest saw `WAITING` again and went back into
the futex without looking at their queues.
- So after a `Bun.build` every worker but one stayed allocated until its
thread next ran a task. With `bpftrace` on main's release build: 13 to
19 `get_worker_slow`, one `Worker::deinit`. RSS after bundling one 2.5
MB file and `Bun.gc(true)`: **~160 MB in 8 of 10 runs, ~60 MB in the 2
where the lucky thread was the one that parsed the file**. With this PR:
57 to 63 MB in 10 of 10.
- The IO pool that reads files on macOS and Windows was never woken, so
its threads kept theirs too.
- The test compares RSS after three bytecode builds against a baseline
taken right after one plain build. Since #42428 `Bun.gc(true)` returns
freed memory at once, so the baseline became bimodal (60 or 160) and the
comparison failed whenever it came out low. Same fixture, 24 runs, 12 at
a time: main fails 2 (deltas 96, 101), this branch 0 (max 31).

Fix, in `src/threading/ThreadPool.rs`: the upper bits of the `Event`
word count `wake_all()` calls, and a waiter drains its idle queue after
publishing `WAITING` and before each sleep. A `wake_all()` that lands
between the drain and the sleep changes the word `Futex::wait` expects,
so nothing is lost; no notification is posted, so every waiter goes back
to waiting. `shutdown()` is a `fetch_or` (SHUTDOWN is both state bits)
and `notify()` no longer overwrites SHUTDOWN. The bundler's pool wakes
its IO pool as well.

`bundlerWorkerLiveCount()` is added to `bun:internal-for-testing` (same
shape as `sslCtxLiveCount`). New test: build, then the count must reach
0; run with and without `BUN_FEATURE_FLAG_FORCE_IO_POOL`. Without the
`Event` change it prints `[15,16]`; without the IO pool wake the
forced-IO-pool row stays at 1.

**2. `fetch-backpressure.test.ts` "stalled no consumer drains the full
body"** (90 s timeout, windows 11 aarch64 only, ~65% of builds flaky,
~5% red; since #40965)

Not a fetch bug: the test's handshake can deadlock. `settled()` waits
for the server to have written more than 8 chunks, the client waits for
`settled()` before it reads, and a client nothing reads pauses its
socket after 256 KiB (`BODY_HIGH_WATER_MARK`, 4 chunks). Where loopback
buffers take fewer than 5 more chunks, the server is waiting for
`'drain'` at 8 chunks or fewer and nobody moves. Reproduced on Linux
with main's CI release build in a netns with `tcp_rmem`/`tcp_wmem`
capped at 64 KiB: the three h1 "no consumer" cases time out. The
threshold is now what a client guarantees to take (the mark). Whole
file, patched: 70/70 at 16 KiB, 64 KiB, 96 KiB and default buffers.

**3. `bun-patch.test.ts` "packages whose label is longer than 1024
bytes"** (Windows, ~60% of builds flaky, ~4% red)

Nothing to do with the label length. `scripts/runner.node.mjs` exports
`BUN_INSTALL_CACHE_DIR`, which wins over the `cache` the harness writes
to each test's bunfig, and two of the three `test.concurrent` cases
install the same tarball spec, so they extract into the same `@T@<hash>`
folder. The test now pins the cache per project, as `bun-dedupe`,
`bun-install-patch` and `bun-install-offline` already do.

That leaves the product side, which this PR does not change: on Windows
the loser of that race renames the winner's folder away and
`delete_tree`s it (`extract_tarball.rs`, the retry branch), where POSIX
does an atomic `renameat2(EXCHANGE)`. The winner then sees `failed to
resolve cache dir: EBADF`, `"package.json" failed to open: ENOENT`, or a
failed hardlink. A local tarball is always re-extracted and keyed only
by its spec string, so "reuse what is there" is not available for it.

### How did you verify your code works?

- New test fails with the `Event` change gutted (`[15,16]`), and its IO
pool row fails with the IO pool wake removed; both pass with the fix on
debug+ASAN and release.
- `bytecode: repeated builds don't retain the generated code`: 10/10 on
this branch's release build, 7/10 on main's.
- 40 rounds each (default and forced IO pool) of 4 concurrent
`Bun.build`s + 16 `readFile`s + 4 `Bun.password.hash` on the same pool,
release and debug: no worker left, no hang. `bun build` CLI (owned pool,
shutdown + join) exits.
- `fs.promises.stat` in a loop, which goes through `Event::notify` on
every call, interleaved against main's release build on 8 cores:
13.9/13.9/14.2 vs 13.3/15.0/14.0 µs/op.
- `test/js/node/fs/fs.test.ts`,
`test/bundler/bundler_splitting.test.ts`,
`test/bake/dev/bundle.test.ts`, `test/bake/deinitialization.test.ts`,
`test/cli/install/bun-patch.test.ts`,
`test/js/web/fetch/fetch-backpressure.test.ts` on the debug build.
- The Windows halves of 2 and 3 are from CI logs and code; this PR's
Windows lanes are the check.
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…ix two CI test deadlocks (oven-sh#42665)

### What does this PR do?

Three of the tests that have been turning builds red over the last few
days, by root cause. (The fourth and largest, `spawn-pipe-leak.test.ts`,
was oven-sh#42428 and is already fixed on main by oven-sh#42571.)

**1. `Bun.build` left all but one bundler `Worker` alive after the
build** (`bun-build-api.test.ts` "bytecode: repeated builds don't retain
the generated code", flaky in 129 of 198 builds on 09-13)

- At the end of a build each pool thread gets an idle task that tears
down its `Worker` (its AST heap, its transpiler), and
`wake_for_idle_events()` wakes the pool. It woke every parked thread but
posted one notification. One thread consumed it, left `Event::wait` and
drained its idle queue. The rest saw `WAITING` again and went back into
the futex without looking at their queues.
- So after a `Bun.build` every worker but one stayed allocated until its
thread next ran a task. With `bpftrace` on main's release build: 13 to
19 `get_worker_slow`, one `Worker::deinit`. RSS after bundling one 2.5
MB file and `Bun.gc(true)`: **~160 MB in 8 of 10 runs, ~60 MB in the 2
where the lucky thread was the one that parsed the file**. With this PR:
57 to 63 MB in 10 of 10.
- The IO pool that reads files on macOS and Windows was never woken, so
its threads kept theirs too.
- The test compares RSS after three bytecode builds against a baseline
taken right after one plain build. Since oven-sh#42428 `Bun.gc(true)` returns
freed memory at once, so the baseline became bimodal (60 or 160) and the
comparison failed whenever it came out low. Same fixture, 24 runs, 12 at
a time: main fails 2 (deltas 96, 101), this branch 0 (max 31).

Fix, in `src/threading/ThreadPool.rs`: the upper bits of the `Event`
word count `wake_all()` calls, and a waiter drains its idle queue after
publishing `WAITING` and before each sleep. A `wake_all()` that lands
between the drain and the sleep changes the word `Futex::wait` expects,
so nothing is lost; no notification is posted, so every waiter goes back
to waiting. `shutdown()` is a `fetch_or` (SHUTDOWN is both state bits)
and `notify()` no longer overwrites SHUTDOWN. The bundler's pool wakes
its IO pool as well.

`bundlerWorkerLiveCount()` is added to `bun:internal-for-testing` (same
shape as `sslCtxLiveCount`). New test: build, then the count must reach
0; run with and without `BUN_FEATURE_FLAG_FORCE_IO_POOL`. Without the
`Event` change it prints `[15,16]`; without the IO pool wake the
forced-IO-pool row stays at 1.

**2. `fetch-backpressure.test.ts` "stalled no consumer drains the full
body"** (90 s timeout, windows 11 aarch64 only, ~65% of builds flaky,
~5% red; since oven-sh#40965)

Not a fetch bug: the test's handshake can deadlock. `settled()` waits
for the server to have written more than 8 chunks, the client waits for
`settled()` before it reads, and a client nothing reads pauses its
socket after 256 KiB (`BODY_HIGH_WATER_MARK`, 4 chunks). Where loopback
buffers take fewer than 5 more chunks, the server is waiting for
`'drain'` at 8 chunks or fewer and nobody moves. Reproduced on Linux
with main's CI release build in a netns with `tcp_rmem`/`tcp_wmem`
capped at 64 KiB: the three h1 "no consumer" cases time out. The
threshold is now what a client guarantees to take (the mark). Whole
file, patched: 70/70 at 16 KiB, 64 KiB, 96 KiB and default buffers.

**3. `bun-patch.test.ts` "packages whose label is longer than 1024
bytes"** (Windows, ~60% of builds flaky, ~4% red)

Nothing to do with the label length. `scripts/runner.node.mjs` exports
`BUN_INSTALL_CACHE_DIR`, which wins over the `cache` the harness writes
to each test's bunfig, and two of the three `test.concurrent` cases
install the same tarball spec, so they extract into the same `@T@<hash>`
folder. The test now pins the cache per project, as `bun-dedupe`,
`bun-install-patch` and `bun-install-offline` already do.

That leaves the product side, which this PR does not change: on Windows
the loser of that race renames the winner's folder away and
`delete_tree`s it (`extract_tarball.rs`, the retry branch), where POSIX
does an atomic `renameat2(EXCHANGE)`. The winner then sees `failed to
resolve cache dir: EBADF`, `"package.json" failed to open: ENOENT`, or a
failed hardlink. A local tarball is always re-extracted and keyed only
by its spec string, so "reuse what is there" is not available for it.

### How did you verify your code works?

- New test fails with the `Event` change gutted (`[15,16]`), and its IO
pool row fails with the IO pool wake removed; both pass with the fix on
debug+ASAN and release.
- `bytecode: repeated builds don't retain the generated code`: 10/10 on
this branch's release build, 7/10 on main's.
- 40 rounds each (default and forced IO pool) of 4 concurrent
`Bun.build`s + 16 `readFile`s + 4 `Bun.password.hash` on the same pool,
release and debug: no worker left, no hang. `bun build` CLI (owned pool,
shutdown + join) exits.
- `fs.promises.stat` in a loop, which goes through `Event::notify` on
every call, interleaved against main's release build on 8 cores:
13.9/13.9/14.2 vs 13.3/15.0/14.0 µs/op.
- `test/js/node/fs/fs.test.ts`,
`test/bundler/bundler_splitting.test.ts`,
`test/bake/dev/bundle.test.ts`, `test/bake/deinitialization.test.ts`,
`test/cli/install/bun-patch.test.ts`,
`test/js/web/fetch/fetch-backpressure.test.ts` on the debug build.
- The Windows halves of 2 and 3 are from CI logs and code; this PR's
Windows lanes are the check.
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.

2 participants