Skip to content

test(serve): speed up bun-server.test.ts and tighten its assertions - #40093

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/1f133c05/speed-up-bun-server-test
Aug 22, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/1f133c05/speed-up-bun-server-test

Conversation

@robobun

@robobun robobun commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/bun/http/bun-server.test.ts takes 9 to 16 s on every CI lane. The time is fixed waits, not work.
  • The GC stress test runs 400 rounds or 8 s, and since Bun.serve: close idle connections on graceful stop(), declare closeIdleConnections() #37074 it tests nothing: a graceful stop() closes the idle parked connections, so every late request meets a closed socket (0 of 4770 dispatched on main).
  • Other fixed costs: 3 x 1000 ms CPU samples during the echo burst, 30-iteration GC loops with 10 ms sleeps, two 100 ms timers in the HEAD probe.

Fix

  • The stress test targets node:http, whose close() leaves busy connections keep-alive afterwards, as in Node. Each round holds a request per parked connection across close(), drops the binding, runs Bun.gc(true), and requires every late request to be answered by the original handler. With the downgrade gate removed it fails in round 1 (3 of 3). On main 162 of 162 late requests are answered.
  • Every other timer becomes the event it waited for (list in the notes). Top-level tests run concurrently where test.concurrent( fits on one line.
  • Stronger checks on full responses, pendingRequests around stop(), exact status lines, and piped stdout/stderr/exitCode for every fixture (list in the notes).
  • Verified, same machine and build: debug+ASAN 88.8 s to 25 s, release 10.4 s to 2.1 s, Windows x64 canary 11.1 s to 2.1 s. Pass set unchanged.

Background

  • Bun.gc(false) is Heap::collectSync: collection only. Bun.gc(true) is Heap::collectNow(Sync), which also sweeps, so dead cells with destructors are finalized inside the call.
  • The downgrade gate (Bun.serve: gate the graceful stop() drain promise on open connections #35130): the JS wrapper is the handlers' only GC root. is_drained() lets it go Weak only when no request, listener, websocket, or connection is left. Without it the wrapper is collected while connections are open and its finalizer closes them.
  • heapStats().objectTypeCounts counts prototypes with instances. count - 1 while one instance is alive is the per-build floor the old 30-iteration drain produced.
Notes

Runs of this revision: debug+ASAN bun bd test 2 runs (25.5 s, 26.2 s), release USE_SYSTEM_BUN=1 5 runs (2.11 to 2.16 s), Windows x64 canary build of the same commit 4 runs (2.10 to 2.12 s, 79 pass). In this container three tests fail on main and on this branch alike (rejected promise handled by error method, parse source map and fetch small stream, abrubtly close a upload request): they bind hostname: "localhost", which lands on ::1 here while the client dials 127.0.0.1. #38818 fixes that in bun, so the fixtures stay as they are. The local runs unset HTTP_PROXY/HTTPS_PROXY: this container's egress proxy does not honor a bare ::1 in NO_PROXY for the new http://[::1]:port/ request (#37429). CI has no proxy.

Stress fixture evidence:

  • main's fixture on main (release, 400 rounds): answered 0, closed 4770. Debug build, 40 rounds: answered 0, closed 366.
  • PR fixture on main: answered 162 of 162, release (about 160 ms) and debug (5.8 s standalone).
  • gate removed (is_drained() returns is_closed()), debug build: FAIL round 1: a parked connection closed, 3 of 3. The finalizer's app.close() lands before the same round's late requests are answered.
  • bun 1.3.14 is not a control for the new fixture: its node:http close() closes busy connections too.
  • The first revision kept the Bun.serve fixture with Bun.gc(true) and 16 rounds (round-2 detection on 1.3.14, 60 of 60, versus round 15 to 153 with Bun.gc(false)). Review found the late requests unreachable on main, hence the retarget.

Assertions added: {status, text} on plain responses, pendingRequests before and after stop() plus the exact status line in the drain fixtures (HTTP/1.1 200 OK, HTTP/1.1 500 Internal Server Error, "" for the force-closed connection), the negotiated subprotocol and the close frame in the custom-protocol test, AbortError plus the exact byte count for the truncated upload, redirected/url on the redirect, Completed: 10 from the unref fixture, and {stdout, stderr, exitCode} via toEqual for every spawned fixture. The nitro fixture runs with PORT=0 instead of port 3000.

Concurrency: six long-named tests (or tests with a timeout argument) stay sequential test( because prettier reflows test.concurrent( calls that exceed 120 columns and would reindent their bodies. The two await server.stop() tests also stay sequential: they assert that a fetch to the freed port fails.

Timers replaced by events: the abort-signal stream tests await the server-side abort; the truncated upload half-closes once the server consumed the chunk; the HEAD probe sends Connection: close and reads to end; the CPU fixture samples 3 x 500 ms after the last echo; the heapStats loops poll objectTypeCounts with a 5 s deadline.

bun test runs consecutive concurrent tests as one group (20 at a time, 5 under ASAN); a plain test( ends the group.

Fixed waits removed: the Bun.sleep(15)/Bun.sleep(10) pairs in the two abort-signal stream tests, setTimeout(..., 100) in the upload test and in doHead, the Bun.sleep(10) in six GC loops, the 8 s floor of the stress test, the 1000 ms CPU windows, and the 30-iteration drain(0) baseline loops. The Bun.sleep(15) in "abort signal on server should only fire if aborted" stays: a negative check with no event to await, inside a concurrent block.

Bun.spawnSync in two "Server" tests blocked the event loop of the concurrent block. Both use Bun.spawn now. tryWritePending kept the written prefix on a partial write (data.slice(0, written)). It keeps the unsent tail now.

Release per-test after the change: the idle-CPU fixture (1.5 s) bounds the file. Everything else is under 0.2 s. No file under src/ changes. The edited CPU fixture is referenced only by this test file.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/bun-server.test.ts

The graceful-stop GC stress test ran 400 rounds or 8 seconds because
Bun.gc(false) only collects: dead cells stay intact until the lazy
sweeper reaches their block, so the unfixed build took 15 to 150 rounds
to crash. Bun.gc(true) also sweeps synchronously, which zaps a collected
wrapper at once, and the unfixed build then crashes in round 2 every
time. Run 16 rounds with a smaller heap churn.

Start the idle-CPU samples after the echo burst and sample 3 x 500 ms.
Poll heapStats() with a deadline instead of fixed 30-iteration loops
with sleeps, and read the prototype floor while the throwaway server is
the only live instance. Await the server-side abort instead of sleeping
in the abort-signal tests, half-close the truncated upload once the
server has consumed the chunk, and end HEAD probes with Connection:
close instead of a 100 ms timer. Run the remaining top-level tests and
describes concurrently where the one-line test form still fits.

Assert full responses (status, headers, body), pendingRequests counts
around stop(), exact status lines, the negotiated subprotocol and close
frame, and piped stdout/stderr of every fixture. Bind the fixtures to
127.0.0.1 and pass PORT=0 to the nitro fixture instead of port 3000.
@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your included review limit has been reached.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

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

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset (next review available in 1 minute), then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 15e885ec-4d35-495b-b15c-59480ba14f03

📥 Commits

Reviewing files that changed from the base of the PR and between abe2ad4 and 8503cfa.

📒 Files selected for processing (2)
  • test/js/bun/http/bun-server.test.ts
  • test/js/bun/http/bun-websocket-cpu-fixture.js

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

@robobun

robobun commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:30 AM PT - Aug 22nd, 2026

✅ @robobun, your commit 8503cfa5e35d5fd20181feaf196a5f33479530e3 passed in Build #103653! 🎉


🧪   To try this PR locally:

bunx bun-pr 40093

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

bun-40093 --bun

@robobun

robobun commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: PR ready for review.

How I measured, same machine and build each time:

  • bun bd test test/js/bun/http/bun-server.test.ts (debug + ASAN): 88.8 s before, 25 to 29 s after (3 runs).
  • USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-server.test.ts (release): 10.4 s before, 2.1 s after (22 runs).
  • Windows x64, canary build of the same commit: 11.1 s before, 2.06 s after (7 runs).

Detection check for the GC stress fixture on bun 1.3.14 (the last release without #35130): the old fixture failed between round 15 and 153 (median 19, 40 runs). The new fixture fails in round 2 in 60 of 60 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.

I reviewed this PR and didn't find any bugs. It's a test-only speedup that follows the repo's test guidelines closely (await events instead of sleeps, stronger assertions, concurrent subprocess tests, drained pipes). Because it's a large diff that also reduces the graceful-stop GC stress test from 400 rounds to 16 (justified by switching to Bun.gc(true) with empirical data on the unfixed build) and reworks how the heapStats prototype floor is measured, a human look at those two coverage-sensitive changes would still be worthwhile.

What was checked:

  • toSpawn("Completed: 10") / toSpawn("") match the fixtures' actual output after bunRun's trim.
  • The new baseline = serverCount() - 1 computation: the trivial server is stopped and unreferenced, so the later drain(baseline) / 5-round GC loops collect it alongside the test server; the existing afterStopGC > baseline and after == baseline assertions remain sound.
  • tryWritePending's data.slice(written) fix keeps the unsent tail (was a real bug in the old test), and the chunkConsumed handshake can't deadlock.
  • Newly-concurrent describes each use port: 0 and per-test servers; the in-process Bun.gc(true) calls in "handler liveness" don't affect correctness of siblings holding live references.
Extended reasoning...

Overview

This PR touches only test code: test/js/bun/http/bun-server.test.ts (~600 diff lines in a ~3400-line file) plus three fixtures it spawns (bun-websocket-cpu-fixture.js, js-sink-sourmap-fixture/index.mjs, rejected-promise-fixture.js). No src/ changes. The goal is to cut the file's fixed-wait cost from ~10-16s to ~2s while tightening assertions.

Security risks

None. Test-only; no auth, crypto, or user-input paths touched. Fixture hostname changes from localhost to 127.0.0.1 are a robustness fix for dual-stack hosts (bind and dial the same address).

Level of scrutiny

Medium-high. Although test-only, two changes trade iteration count for determinism in tests that guard against use-after-free regressions in the HTTP server (js_value downgrade gate from #35130):

  1. The graceful-stop GC stress fixture drops from 400 rounds / 8s to a fixed 16 rounds by switching Bun.gc(false) → Bun.gc(true). The author's reasoning (synchronous sweep zaps dead cells immediately, so a collected wrapper faults on the very next dispatch) is correct per JSC semantics, and the description reports 60/60 round-2 failures on bun 1.3.14. This is a well-argued change but reducing a stress test's iteration count is the kind of coverage decision a maintainer should sign off on.
  2. The heapStats prototype-floor baseline is now read as count - 1 while a throwaway server is the only live instance, replacing a 30-iteration drain(0) that could never exit early. I traced the assertions (afterStopGC > baseline, after == baseline) and confirmed the still-uncollected trivial server is swept by the subsequent GC loops before those checks, so the invariant holds — but it's subtle.

Other factors

Everything else lines up with REVIEW.md's test rules and is strictly a strengthening: sleeps replaced with awaited events (abort resolvers, chunkConsumed, Connection: close + end for the HEAD probe, echo-count gate in the CPU fixture); spawnSync → spawn inside a concurrent describe so the event loop isn't blocked; every spawned fixture now asserts {stdout, stderr, exitCode} via toEqual; the upload test's expect moved out of a catch block into a resolved payload; a real bug in the test's partial-write path (slice(0, written) → slice(written)) is fixed; the WebSocket protocol test now checks the negotiated subprotocol, echoed data, and close frame (and dropped a stray console.log). I verified the new toSpawn(...) stdout expectations against the fixtures and bunRun's trim, and that the nitro fixture's log line matches the new normalized-stdout assertion.

Given the size and the two coverage-sensitive judgment calls, deferring to a human reviewer rather than shadow-approving.

@robobun

robobun commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

On the two coverage-sensitive points:

  1. Round count of the GC stress fixture. The old fixture needed many rounds because Bun.gc(false) (Heap::collectSync) does not sweep. A collected wrapper kept its bytes until the lazy sweeper reached its block, so the late request kept working for a while. With Bun.gc(true) (Heap::collectNow(Sync) plus sweepSynchronously()) the wrapper is destroyed and zapped in the same call. On bun 1.3.14 (the last release without Bun.serve: gate the graceful stop() drain promise on open connections #35130) the old fixture first failed between round 15 and 153 over 40 runs, the new one fails in round 2 in 60 of 60 runs, with the full churn, the reduced churn, and no churn. 16 rounds is an 8x margin over that, and each server kind still gets eight rounds.

  2. Prototype floor. count - 1 while the throwaway server is the only instance equals the old drain(0) result by construction: the old loop could never reach 0, so it ran all 30 iterations and then read prototypes plus zero instances. The throwaway server is stopped with no connections, so the same final drain that must collect the test server collects it too. after == baseline still requires both to be gone. Verified on Linux (debug and release) and on Windows x64 with the canary build, where the floor can be 2.

…keep the localhost fixtures

The stress fixture parked keep-alive connections on a Bun.serve server
and stopped it gracefully. Since #37074 that stop() closes idle
connections inside the call, so every late request found a closed socket
and the fixture's failure branches never ran (0 of 162 late requests
dispatched on main, release and debug). node:http close() skips busy
connections and leaves them keep-alive afterwards, as in Node, so the
fixture now holds one request per connection across close(), drops the
binding, GCs with a synchronous sweep, and requires every late request
to be answered by the original handler. With the downgrade gate removed
(is_drained without the connection term) it fails in round 1, 3 of 3
runs under the debug build; on main 162 of 162 late requests are
answered.

Keep the three hostname: "localhost" fixtures as they are. They
reproduce the loopback resolution asymmetry that #38818 fixes in bun.

@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 de-flaking and assertion-tightening changes look correct. Because the graceful-stop GC stress fixture is a full rewrite — it retargets from Bun.serve (fetch + routes: trampolines, with a FinalizationRegistry "collected server answered" check) to node:http only, and flips "connection closed" from acceptable to a failure — a human should confirm that the #37074 rationale really makes the old Bun.serve/routes: coverage vacuous rather than dropped.

What was reviewed:

  • toSpawn("") / toSpawn("Completed: 10") against the unref fixtures — bunRun trims stdout so both match.
  • The nitro fixture stdout regex vs js-sink-sourmap-fixture/index.mjs:5597, and PORT=0 is read at line 5580.
  • tryWritePending now keeps data.slice(written) (unsent tail) instead of the written prefix.
  • Every describe moved to .concurrent runs its heapStats/liveness cases in separate subprocesses, so they don't share objectTypeCounts.
Extended reasoning...

Overview

Test-only change to test/js/bun/http/bun-server.test.ts and its bun-websocket-cpu-fixture.js. It replaces fixed sleeps with awaited events, converts spawnSync to async Bun.spawn with piped/asserted {stdout, stderr, exitCode}, moves independent tests/describes to test.concurrent/describe.concurrent, tightens assertions to full-object toEqual shapes, fixes a real bug in the upload test's tryWritePending (kept the written prefix instead of the unsent tail), and rewrites the graceful-stop GC stress fixture. No src/ code changes.

Security risks

None. All changes are to tests and a test fixture; no auth, crypto, or runtime code is touched.

Level of scrutiny

Medium-high. The mechanical parts (await-the-event, concurrent describes, combined-object assertions, Connection: close in the HEAD probe, Bun.gc(true) in drain loops) are exactly what REVIEW.md prescribes and are low-risk. The GC stress fixture rewrite is not mechanical: the old fixture alternated Bun.serve({ fetch }) and Bun.serve({ routes: { "/r/:id" } }) rounds and used a FinalizationRegistry to detect "collected server answered"; the new fixture uses only node:http.createServer, drops the FinalizationRegistry, and treats a closed parked connection as a failure instead of acceptable. The stated rationale is that #37074 makes Bun.serve mark busy connections close-when-idle at stop(), so a late request there never reaches a handler and the old rounds were passing vacuously via the if (!r) continue escape hatch. If that's accurate, the retarget is a strengthening; if not, it drops the paramsObjectForRoute-on-a-swept-cell path the old comment called out. That's a judgment about the runtime's current behavior that a human familiar with #35130/#37074 should confirm — REVIEW.md is explicit that de-flaking must keep asserting the property the original assertion protected.

Other factors

The bug-hunting pass found nothing. I checked that bunRun trims stdout so toSpawn("Completed: 10") matches console.log("Completed:", 10) in unref-fixture-2.ts, that the nitro fixture reads process.env.PORT (so PORT: "0" takes effect) and prints the exact line the new regex normalizes, and that every describe moved to .concurrent runs its heapStats cases inside a spawned subprocess so process-global objectTypeCounts are not shared. The doHead change to Connection: close + read-to-end is strictly stronger than the old 100 ms wait for stray body bytes. The one open question — whether the Bun.serve routes: trampoline still needs its own GC-stress coverage — is why I'm deferring rather than approving.

@robobun

robobun commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

On whether the old Bun.serve / routes: rounds were vacuous rather than dropped, the measurement and the code path:

  • Measured. I instrumented main's fixture (unchanged except for counters on the late-request results) and ran it on main: release build, 400 rounds, 4770 late requests, 0 answered, 4770 resolved null. Debug build, 40 rounds, 366 late requests, 0 answered. Every round took the if (!r) continue; // connection closed: acceptable branch. The wrong body and collected server answered branches never ran, so the FinalizationRegistry check never had anything to compare.
  • Why. Graceful stop() on a Bun.serve server runs app.close_idle_connections(true) in stop_listening (src/runtime/server/mod.rs, the non-node:http branch), which reaches uWS closeIdle and us_socket_close for every socket whose response is done. Both parked connections are in that state when the fixture calls stop() (it awaits their first response and a setImmediate first), so the sockets are closed inside the stop() call. That is the behavior Bun.serve: close idle connections on graceful stop(), declare closeIdleConnections() #37074 added, one day after Bun.serve: gate the graceful stop() drain promise on open connections #35130 added the fixture, and it is what the drain test stop() closes an idle keep-alive connection and the promise resolves in this file pins. The comment // graceful: parked connections stay open in the old fixture stopped being true at that point.
  • The routes: path. On main a request can only reach a Bun.serve handler after a graceful stop() if its connection was spared by the sweep, which means busy or mid-request at the sweep. The drain test a connection mid-request survives stop() until the client closes covers that dispatch, with the server binding still in scope. The paramsObjectForRoute-on-a-swept-cell path the old comment named was not reachable through parked idle connections on any build since Bun.serve: close idle connections on graceful stop(), declare closeIdleConnections() #37074, so the old rounds were not covering it either. node:http is the one server kind whose connections stay keep-alive after a graceful close on main, which is why the fixture targets on_node_http_request_with_upgrade_ctx, the trampoline that can dispatch late without staging a partial request head.
  • The new fixture is not vacuous: 162 of 162 late requests are answered on main (release and debug), and with the connection term removed from is_drained() it fails in round 1, 3 of 3, under the debug build. Treating a closed parked connection as a failure is what catches that: without the gate the collected wrapper's finalizer closes the connections.

@Jarred-Sumner
Jarred-Sumner merged commit 9a81830 into main Aug 22, 2026
5 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/1f133c05/speed-up-bun-server-test branch August 22, 2026 23:51
robobun added a commit that referenced this pull request Aug 23, 2026
One conflict, in test/js/bun/http/bun-websocket-cpu-fixture.js: main's
version (#40093) waits for the echo burst to finish before sampling, which
supersedes this branch's drain-then-settle change, so main's file is taken
as is.
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