Skip to content

Bun.serve: read a regular file whose st_size is 0 to its end - #43974

Draft
robobun wants to merge 1 commit into
farm/0d5eeba7/fifo-response-eof-framingfrom
robobun/00c06367/serve-file-unknown-size
Draft

robobun wants to merge 1 commit into
farm/0d5eeba7/fifo-response-eof-framingfrom
robobun/00c06367/serve-file-unknown-size

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #44064

Stacked on #37082. Questions for alii or Jarred-Sumner:

  1. Is this shape right? Leave a body stream unsized when the file's st_size is 0 #41593 has the same decision open.
  2. Before or after Bun.serve: resolve a file body for HEAD the same way as for GET #41585 (HEAD) and Bun.file: do not use a cached stat size as the byte budget of a read or a copy #43910 (Blob size)?

Problem

  • Bun.serve answers Content-Length: 0 and no body for new Response(Bun.file("/proc/version")). The file has 219 bytes.
  • do_sendfile (RequestContext.rs:1857) and FileRoute::serve (FileRoute.rs:335) frame from st_size. procfs and cgroupfs report 0.

Fix

  • A regular file with st_size 0 takes the arm of a pipe: FileResponseStream reads it to EOF, and the producer writes no Content-Length, Range or validator. Linux only.
  • A slice passes its window as BodyLength::UpTo, the limit of the reader.
  • Verified: bun-serve-file.test.ts (26 new tests, 21 fail on the base). Also serve-directory-routes, bun-serve-static, serve-http2.
  • Self-reviewed: 45 concerns raised on the revision before, 42 addressed.

Background

Downsides

Notes

What the server sends

Raw HTTP/1.1, Connection: close, Linux x64, release builds. Base: #37082 at 26376b9. Revision before: 64489d0, which read the file before the status line. /proc/version has 219 bytes, /proc/cpuinfo 177,934 and /proc/kallsyms 12.5 MB here.

request base revision before this revision
GET /proc/version, fetch handler and static route Content-Length: 0 Content-Length: 219 Content-Length: 219
GET Bun.file("/proc/version").slice(10, 60) Content-Length: 0 Content-Length: 50 Content-Length: 50
GET /proc/cpuinfo (65 reads) Content-Length: 0 Content-Length: 177934 chunked, whole body, last chunk
GET /proc/kallsyms Content-Length: 0 Content-Length: 0 chunked, whole body, last chunk
GET with Range: bytes=0-9 416, Content-Range: bytes */0 200, whole body 200, whole body
GET of an empty file, fetch handler Content-Length: 0 Content-Length: 0 Content-Length: 0
GET of an empty file, static route Content-Length: 0, Last-Modified Content-Length: 0, Last-Modified Content-Length: 0
GET /proc/self/mem (the first read fails with EIO) empty 200 500 through error(), or the next handler the connection closes with no byte
HEAD /proc/version Content-Length: 0 no Content-Length Content-Length: 0
TRACE in a fetch handler Content-Length: 0 Content-Length: 0 Content-Length: 0
GET of a BunFile after .size or await exists() 0 bytes 0 bytes 0 bytes
GET Bun.file(fd), two times 0 bytes, position 0 219 bytes, then 0 bytes, position 219 0 bytes, position 0
GET with If-None-Match: *, static route 304, Last-Modified 304 304
TLS with HTTP/1.1, HTTP/2 and HTTP/3, 219 bytes and 480 KB 0 bytes 219 bytes, 0 bytes 219 bytes, 480 KB

A read error gets the answer that a file with a size gets on the base: the connection closes.

Measurements

Release builds of the base (26376b9) and of this PR (1924ab8), same machine and toolchain. strace, perf and valgrind are not installed on the test machine and perf_event_open is not permitted there. The counts come from a ptrace syscall counter, a ptrace single-step counter and gdb breakpoints on the mimalloc entry points.

User instructions per request from the return of recvfrom to the first send (single-step count, 20 requests after 40 warm-up requests):

request base this PR
sized 219 B GET, static route 13,985 14,001 (+16)
sized 219 B HEAD, static route 12,687 12,702 (+15)
sized 219 B GET, directory route 15,602 15,607 (+5)
sized 219 B GET, fetch handler (least of 20) 26,340 26,350 (+10)
sized 219 B HEAD, fetch handler (least of 20) 22,943 22,942
empty GET, static route 12,942 12,435
empty GET, fetch handler (median) 25,671 25,943
/proc/version GET, static route 12,974 12,822
/proc/version GET, fetch handler (median) 25,436 26,009

The route counts are equal in all 20 requests. The fetch handler runs JavaScript, so its count changes with the work of the garbage collector. By function, for a sized file: FileRoute::on +11, do_sendfile +4, FileResponseStream::start +5.

Syscalls per request on one keep-alive connection, (count at 300 requests minus count at 100) / 200, futex left out:

request base this PR
sized 219 B GET: fetch handler, static route, directory route 7 7
sized 219 B GET, handler that resolves after setImmediate 13 (7 sendto) 13 (7 sendto)
sized 219 B HEAD: fetch handler / static route 4 / 6 4 / 6
sized 600 KiB GET: fetch handler, static route 12 12
sized 2 MiB GET (sendfile): fetch handler, static route 7 7
empty file GET: fetch handler, static route 6 7 (one read that returns 0)
empty file HEAD: fetch handler / static route 4 / 6 4 / 6
/proc/version GET: fetch handler, static route 6 8 (two read)
/proc/version GET, handler that resolves after setImmediate 11 (6 sendto) 16 (two read, 9 sendto)
/proc/version HEAD: fetch handler / static route 4 / 6 4 / 6
/proc/cpuinfo GET, fetch handler 6 78 (65 read, 8 sendto)

Heap allocations per request (gdb breakpoints on mi_malloc, mi_calloc, mi_zalloc, mi_realloc and the aligned and heap variants, count at 500 requests minus count at 100, divided by 400). Two runs of the same build differ by up to 0.2 on a route and 0.5 on a handler:

request base this PR
sized 219 B GET, static route 2.03 2.02
sized 219 B GET, directory route 2.01 2.08
sized 219 B GET, fetch handler 14.14 14.55
empty GET, static route 1.06 1.81
empty GET, fetch handler 12.17 14.40
/proc/version GET, static route 0.89 2.09
/proc/version GET, fetch handler 12.69 13.73

A file with no size allocates what a file with a size allocates: the FileResponseStream and the task that closes the descriptor.

Time that one GET holds the event loop (greatest gap of a 1 ms timer, median of 15 requests, uid 0, load average over 400):

file base this PR
/proc/version (219 B) 0.5 ms 0.4 ms
/proc/self/smaps_rollup (698 B, mode 0444) at 0.03, 0.5, 1 and 2 GiB resident 0.7 ms to 1.1 ms 1.5 ms, 16 ms, 20 ms, 44 ms
/proc/cpuinfo (178 KB) 0.4 ms 2.7 ms
/proc/slabinfo (45 KB, mode 0400) 0.4 ms 53 ms
/proc/kallsyms (12.5 MB) 0.4 ms 99 ms
/proc/pagetypeinfo (3.9 KB, mode 0400) 0.4 ms 134 ms to 179 ms
/proc/vmallocinfo (2.2 MB, mode 0400) 0.4 ms 275 ms to 280 ms

The time is the time the kernel takes to make the content. The reader of FileResponseStream runs read on the JS thread for each file that does not go through sendfile, on the base too. The four Dockerfiles in dockerhub/ set no USER, so the images run as uid 0, which can read the files of mode 0400.

Binary size:

base this PR
.text of bun-profile (size) 80,855,937 80,857,473 (+1,536)
bun, stripped, file bytes 80,995,912 81,000,008 (+4,096)

From nm -S: do_sendfile +184 in each of its 8 copies, FileRoute::on +98, FileResponseStream::start +29. StartOptions and FileResponseStream keep their size: BodyLength has the 16 bytes that Option<u64> had.

Source: +91 -21 lines in 4 files.

Tests

  • New: 26 tests and 3 test.todo in test/js/bun/http/bun-serve-file.test.ts ("Bun.file() of a regular file whose stat size is 0"), Linux only. On a release build of the base, 21 fail. The 5 others are guards, with a comment that says so: an empty file (4) and Bun.file(fd) (1).
  • Every expected byte comes from a file that does not change: /proc/version and two files of a child process that stopped itself with SIGSTOP. Its environ has about 480 KB. Its maps has 64 added mappings and comes in reads of at most one page.
  • The tests do not name the framing of a large body. They parse the response and fail when a body is not the length of its Content-Length or when chunks have no last chunk.
  • One test sends 11 requests on one keep-alive connection and finds no byte between the responses.
  • One test sends HEAD and then GET to a static route. It fails on a build without the reset of the stored Last-Modified.
  • Debug build with ASAN: bun-serve-file.test.ts, serve-directory-routes.test.ts, serve-file-slice-read-error.test.ts, bun-serve-static.test.ts, serve-http2.test.ts, serve-if-none-match.test.ts. cargo clippy -p bun_runtime and bun run rust:check-all pass.
  • The machine of these runs had a load average over 400. Three tests of bun-serve-file.test.ts that this PR does not change ("pollable Bun.file ...") start a debug build of bun and ended at the 5 s limit there, with the source of main too.

Limits

Self-review

The review of the revision before raised 45 concerns. Its two blockers asked for this shape: one reader, two producers, after #37082. The directory route, the answer for HEAD and the read before the status line left the PR. Not taken:

  • A predicate in bun_sys that the sibling PRs can call. reports_no_size stays in the server. The reads of the siblings are in other crates and other PRs.
  • A history of the answer in 1.3.12, 1.3.14 and 1.4.2. I did not run those releases. serve: consolidate file streaming into FileResponseStream; Range support; fix aborted-stream hang #29202 moved the file bodies into FileResponseStream, and c600196 fixed the same class in readFileSync.
  • A test that fails a read after the first. The reads are the ones of the reader of the base, and serve-file-slice-read-error.test.ts covers them for a file with a size.

Other PRs

PR Relation
#37082 The base. FileResponseStream::finish ends with resp.end(b""), which writes the last chunk.
#34242 The first attempt, for files with no slice. The stale sweep closed it on 2026-09-13 with no comment from a maintainer.
#41585 Routes HEAD of a fetch handler through do_sendfile. It changes the same lines of do_sendfile.
#43910, #41593 The window of 0 bytes in Blob::resolve_size. #43910 changes RequestContext.rs and this test file.
#43871, #43897, #43964, #43965, #44026, #41590 The same class in other readers: Bun.file reads, fetch bodies, the module loader, dotenv, FormData. No file in common.
#41626 Reads of Bun.file().stream() on the work pool. It does not change FileResponseStream.
#42819 Removes libuv on Windows and changes the same files. The code here does not run on Windows.

Found on the way, not in this PR: a comment in uws_res_end_without_body (src/uws_sys/libuwsockets.cpp) names FileResponseStream::finish as a caller. With #37082 it is not one.

The branches robobun/00c06367/serve-file-whole-body (64489d0) and robobun/00c06367/serve-file-chunked-body (86b62a0) hold the two shapes that read the file before the status line.

@robobun

robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft, stacked on #37082. This revision (1924ab8) sends such a file through the reader that Bun.serve has for pipes. It replaces the revision that read the file before the status line. The questions at the top of the description need an answer from a maintainer.

How to reproduce on main or bun 1.4.2, Linux:

await using server = Bun.serve({ port: 0, fetch: () => new Response(Bun.file("/proc/version")) });
const res = await fetch(server.url);
console.log(res.headers.get("content-length"), (await res.text()).length);
// main: "0" 0
// this PR: "219" 219

The same happens for a static route. #44064 tracks the parts that this PR does not change.

@robobun
robobun force-pushed the robobun/00c06367/serve-file-unknown-size branch from 5561b9f to 86b62a0 Compare September 25, 2026 22:51
@robobun

robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:46 PM PT - Sep 28th, 2026

❌ @robobun, your commit 1924ab8 has 1 failures in Build #121422 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43974

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

bun-43974 --bun

Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/js/internal-for-testing.ts Outdated
Comment thread src/js/node/net.ts Outdated
Comment thread src/jsc/bindings/ZigSourceProvider.cpp Outdated
Comment thread src/jsc/bindings/node/http/llhttp/http.c Outdated
Comment thread src/jsc/bindings/node/http/llhttp/http.c Outdated
Comment thread src/jsc/bindings/node/http/llhttp/http.c Outdated
Comment thread src/jsc/bindings/node/http/llhttp/llhttp.h Outdated
Comment thread src/jsc/bindings/node/http/llhttp/llhttp.h Outdated
Comment thread src/jsc/bindings/node/http/llhttp/llhttp.h Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/uws/lib.rs Outdated
@robobun
robobun changed the base branch from farm/0d5eeba7/fifo-response-eof-framing to main September 26, 2026 22:33
A regular file on procfs or cgroupfs reports st_size 0 and has content.
do_sendfile and FileRoute framed the response from st_size, so they
sent Content-Length: 0 and no body.

Such a file now takes the arm of a pipe: FileResponseStream reads it to
EOF, with no Content-Length, Range or validator from the stat. A slice
passes its window as the limit of the reader. HEAD, TRACE and a
descriptor of the caller keep the answer of the stat.
@robobun
robobun changed the base branch from main to farm/0d5eeba7/fifo-response-eof-framing September 29, 2026 00:32
@robobun
robobun force-pushed the robobun/00c06367/serve-file-unknown-size branch from 64489d0 to 1924ab8 Compare September 29, 2026 00:32
@robobun robobun changed the title Bun.serve: read a regular file whose st_size is 0 before framing the response Bun.serve: read a regular file whose st_size is 0 to its end Sep 29, 2026
@robobun

robobun commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

@alii this PR needs one decision from you, and it is the decision that is open on #41593 too.

A regular file on procfs or cgroupfs reports st_size 0 and has content. Bun.serve sends it as an empty 200 today. Two shapes can send the bytes:

A: through the reader (this PR) B: read before the status line
Source lines +91 -21 +428 -6
/proc/version (219 B) Content-Length: 219 Content-Length: 219
/proc/cpuinfo (178 KB) chunked Content-Length: 177934
/proc/kallsyms (12.5 MB) chunked, whole body needs a second path for bodies over 256 KiB
The first read fails the connection closes, as for a file with a size error() gets the error
Empty file one read, 1 to 2 allocations one read
Needs #37082 yes for bodies over 256 KiB

Shape A is on this branch. Shape B is on robobun/00c06367/serve-file-whole-body. The description has the measurements for A.

Questions:

  1. Do you want shape A, shape B, or no change?
  2. Must this land before or after Bun.serve: resolve a file body for HEAD the same way as for GET #41585 (HEAD) and Bun.file: do not use a cached stat size as the byte budget of a read or a copy #43910 (Blob size)? All three change do_sendfile.
  3. Must a { dir } route send such files too? This PR leaves that route as it is.

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