Skip to content

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

Open
robobun wants to merge 3 commits into
mainfrom
robobun/ca265531/speed-up-bun-serve-file-test
Open

robobun wants to merge 3 commits into
mainfrom
robobun/ca265531/speed-up-bun-serve-file-test

Conversation

@robobun

@robobun robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun-serve-file.test.ts takes 31 s on debian 13 x64-asan (build 118779).
  • sendfile does not overtake a buffered response header tail is 14 s of a 24 s local debug run. Most of that is not the race. The server validates the 16 MB x-pad header for each response. The client copies and scans each chunk.
  • Stress responses are compared only with each other. The POST If-Modified-Since test never reaches the file route.

Fix

  • The sendfile test builds the Headers once. The client receives into one buffer, compares the pad with equals(), sets SO_RCVBUF to 16 KB, and sends 8 requests, not 16.
  • The small window makes fewer requests enough. An unfixed debug build fails the new test in 18 of 18 runs, and the old test in 9 of 11.
  • Weak assertions now check actual values. The Notes list each one.
  • Verified: bun bd test test/js/bun/http/bun-serve-file.test.ts goes from 24.0 s to 12.7 s (medians of 3 alternating runs). Release: 9.3 s to 5.0 s.

Background

  • On Linux, Bun.serve sends a file body of 1 MB or more with sendfile(2), directly to the socket. The headers go through a userspace buffer. Bun.serve: keep the first sendfile behind a buffered response header tail #37100 fixed a race where file bytes overtook the buffered tail of the headers.
  • The race needs kernel send-buffer space to open between the header write and the first sendfile call. A client read opens it with a window-update ACK.
  • SO_RCVBUF sets the client's receive buffer. At 16 KB the header write always stalls, and each recv() sends a window update.
Notes

Where the time went (debug ASAN build, local)

test before after
whole file, bun bd test 23.1 s, 24.0 s, 28.4 s 12.4 s, 12.7 s, 12.9 s
sendfile header-tail test 14.2 s, 14.2 s, 16.0 s 3.4 s, 3.4 s, 3.4 s
whole file, release build 9.1 s, 9.3 s, 9.3 s 4.3 s, 5.0 s, 5.9 s
whole file, CPU time (user + sys) 50 s to 57 s 28 s to 29 s
whole file, CI, debian 13 x64-asan 30.5 s (build 118779) 15.6 s (build 118934)

The CI row is one run each, on different days, so it confirms the direction and not an exact ratio. The new file also passes on a Windows 11 aarch64 machine (3 of 3 runs, 5.0 s, the same as before). The runs alternate old and new on one shared host. The host was busy for part of the session (load average 75 to 100). Six more alternating pairs under that load gave 36.6 s to 54.1 s for the old file and 20.2 s to 30.5 s for the new file.

On a debug build the old sendfile test spent about 70 ms of each request on new Headers({ "x-pad": <16 MB> }), and about 75 ms of each request in the client on indexOf scans of the pad. Neither is part of the race.

Detection on unfixed builds

I made scratch builds with the get_buffered_amount() check in FileResponseStream::on_sendfile removed (debug and release). They are not part of this PR. "Hit rate" is the share of requests in which the client saw a file byte before the header terminator.

client debug hit rate release hit rate
old client 0.7% to 2.5% 2.4% to 13.5%
new client, no SO_RCVBUF 0.7% to 12.8% 0.5% to 3.9%
new client, SO_RCVBUF 256 KB 10% to 14% 6% to 20%
new client, SO_RCVBUF 64 KB 14% to 33% 31% to 39%
new client, SO_RCVBUF 16 KB (this PR) 40% 86%

Each figure comes from 18 to 24 client processes. A faster client alone made the release hit rate worse: the client drained so fast that the kernel often took the whole header block, and then no tail was buffered. The small window fixes that. With the debug log scope on, 48 of 48 requests had a buffered tail of 8.7 MB to 13.5 MB at the first sendfile call.

Whole test against the unfixed builds:

  • New test: fails 18 of 18 runs on debug (first wave, under 3.2 s) and 18 of 18 runs on release (about 0.1 s).
  • Old test: fails 9 of 11 runs on debug (3.8 s to 16.2 s for a failed run) and 10 of 10 runs on release.

The new test passes on the fixed builds in 27 of 27 debug runs and 21 of 21 release runs.

Assertion changes

  • Sendfile test: the client compares the whole pad and the whole 4 MB body (the old client sampled one body byte in each 64 KB). It also checks the status line and Content-Length. The test requires the exact client output, an empty stderr and exit code 0. The first failure of any kind ends the client.
  • Stress tests: each response is compared with the file content. Before, each response was compared with the first response, so 16 equal wrong bodies passed.
  • ignores If-Modified-Since for non-GET/HEAD requests: /hello.txt takes only GET and HEAD, so the POST went to the fallback handler and not.toBe(304) passed there. The test now uses /hello-blob.txt, checks that GET gives 304, and that POST gives 200 with the body.
  • ignores Range for non-GET/HEAD methods: not.toBe(206) is now status 200, no Content-Range, and the full body.
  • The slice window test compares all 100 bytes, not the first and the last.
  • Content-Type checks use the exact value, not a regular expression.
  • The If-Match, If-Unmodified-Since, If-None-Match and past If-Modified-Since tests now check the body for GET and for HEAD. Before, several checked only the status, and two checked the body only for GET. For GET this is a real check. For HEAD it is weak: fetch() reads no body for HEAD, so "" shows only what the client exposes. I confirmed this with a raw server that sends 13 body bytes after a HEAD response head: res.text() still gives "". A body that a server leaks on a HEAD response needs a raw-socket test. This PR does not add one.
  • The abort test checks for a DOMException with the name AbortError.
  • The late request body test checks that stderr is empty.
  • serves large file uses a small helper that reports the first character that differs. It replaces 20 lines of console.log.

Not changed, and why

  • Response(Bun.file) does not double-close the fd on Windows is most of the time on Windows (3.3 s of 5.1 s on a Windows 11 aarch64 machine). Its 160 rounds are its detection budget: io(windows): honor CLOSE_HANDLE in WindowsBufferedReader close path #33931 measured 5 of 15 fail-before runs. I did not reduce it.
  • I did not make more top-level tests concurrent. The three pollable FIFO tests have the default 5 s timeout. On the busy host a pollable FIFO test hit that timeout in 4 of 25 whole-file debug runs (2 runs of the old file, 2 runs of the new file). More concurrency in that group raises that risk.
  • The request counts of the stress tests and the memory test are the same.
  • test: enable stale todo tests that now pass #39058 edits the describe.todo("Range requests") block. This PR does not touch it.

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

The sendfile header-tail test took about 60% of the file's time on a
debug build. Most of that time was not part of the race it detects:

- The server validated the 16MB x-pad header value again for each
  response. The test now builds the Headers object once.
- The client copied each chunk and scanned it with indexOf. It now
  receives into one buffer and checks the pad with equals().

The client also sets SO_RCVBUF to 16KB. The small receive window makes
each header write stall with a buffered tail, and it makes the
window-update ACKs frequent. An unfixed build now fails the test in
the first wave of clients, so each client runs 8 requests, not 16.

The client checks more: the whole pad, the whole file body, the status
line and Content-Length. The test requires the exact client output, an
empty stderr and exit code 0.

Other assertions in the file now check actual values:

- The stress tests compare each response with the file, not with the
  first response.
- The POST If-Modified-Since test uses a route that takes POST. The
  old route sent the POST to the fallback handler.
- Content-Type checks use the exact value.
- Range on POST, the slice window, If-Match and If-Unmodified-Since
  results check the status and the body.
- The abort test checks for a DOMException named AbortError.
- The late request body test checks stderr.
@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c5668267-5dc7-4878-9469-3d70d30a79ef

📥 Commits

Reviewing files that changed from the base of the PR and between 5751b11 and 0ae7930.

📒 Files selected for processing (1)
  • test/js/bun/http/bun-serve-file.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

Changes

The HTTP file-serving tests now use exact content, header, status, body, and error assertions. Conditional requests, ranges, streaming regressions, and concurrent sendfile responses receive expanded coverage.

HTTP file-serving tests

Layer / File(s) Summary
Response content assertions
test/js/bun/http/bun-serve-file.test.ts
Adds shared text comparison and exact assertions for content types, lengths, bodies, and abort errors.
Conditional and range request behavior
test/js/bun/http/bun-serve-file.test.ts
Verifies method-dependent conditional responses, empty failure bodies, non-GET range behavior, and exact sliced bytes.
Streaming and request-body regressions
test/js/bun/http/bun-serve-file.test.ts
Checks concurrent response content and clean late request-body streaming behavior.
Concurrent sendfile wire validation
test/js/bun/http/bun-serve-file.test.ts
Uses bounded receive buffers, incremental header checks, exact body validation, and per-wave failure assertions for concurrent responses.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main changes: faster execution and stronger assertions for bun-serve-file.test.ts.
Description check ✅ Passed The description explains the problem, fix, performance results, verification runs, and assertion changes. It does not use the template headings exactly, but it provides the required information and is…

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 test/js/bun/http/bun-serve-file.test.ts — nit, pre-existing: the HEAD variant of "If-Match: matching tag on a file route with ETag → 200" still asserts nothing about the body, so a HEAD that leaks "Hello, World!" passes. Line 496 keeps if (method === "GET") expect(await res.text())... while every sibling in this describe was changed to the unconditional ternary form. Fix: assert the body unconditionally, expect(await res.text()).toBe(method === "GET" ? "Hello, World!" : ""), matching lines 505, 543 and 571.

    Extended reasoning...

    The describe.each(["GET", "HEAD"]) block at lines 480-573 runs each test for both methods. The PR converted lines 505, 543 and 571 from status-only checks to expect(await res.text()).toBe(method === "GET" ? "Hello, World!" : ""), so those tests now fail if a HEAD response carries a body. Line 496 was left as a conditional assertion: for method === "HEAD" no expect on the body runs at all. REVIEW.md lists conditional assertions among the vacuous patterns to hunt. Consequence relative to base: none (this line is unchanged), but it is the one site in the tightened block that still cannot fail for HEAD. The fix is the same one-line ternary the sibling sites use.

    Verification: pre-existing (nit). Triggering condition: the HEAD iteration of describe.each(["GET", "HEAD"]) at test/js/bun/http/bun-serve-file.test.ts:480. Line 496 is unchanged from base (base line 498): if (method === "GET") expect(await res.text()).toBe("Hello, World!"); — for method === "HEAD" no assertion on the body executes, so a HEAD response carrying "Hello, World!" would pass this…

… tests

Two tests in bun-serve-file.test.ts checked the body only for GET. They
now use the same expression as the other tests in the block.

fetch() reads no body for HEAD. The "" that these tests expect for HEAD
shows only what the client exposes. A comment in the block says so.
@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Applied in 0ae7930. The flagged test and one more with the same pattern (ignores If-Modified-Since when If-None-Match is present and does not match) now assert the body for GET and HEAD alike. No conditional body assertion remains in the file.

One correction to the premise, because it also applies to the sibling sites that this PR changed earlier. The unconditional form does not catch a HEAD response that leaks a body. fetch() reads no body for HEAD. I checked this with a raw Bun.listen server that sends Content-Length: 13 and 13 body bytes after a HEAD response head:

GET  -> 200 "Hello, World!"
HEAD -> 200 ""

So for GET these assertions are a real check. For HEAD, "" shows only what the client exposes. I added a two-line comment in the block that says this, and I corrected the Notes in the PR body, which read as stronger than that.

A leaked HEAD body is visible only on the wire, so it needs a raw-socket test like the wire() helper in the slice framing test. I did not add one here. It is outside a speed pass, and #41585 already works on HEAD handling for file bodies.

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:16 AM PT - Sep 20th, 2026

✅ @robobun, your commit 1a19265c05ab467ef753baa32e1c08eb3db8e2a2 passed in Build #118945! 🎉


🧪   To try this PR locally:

bunx bun-pr 43627

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

bun-43627 --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