Skip to content

Bun.serve: read a Bun.file(fd) body at its offset, not at the descriptor's position - #45266

Open
robobun wants to merge 5 commits into
mainfrom
robobun/ad22d453/serve-fd-positional-read
Open

robobun wants to merge 5 commits into
mainfrom
robobun/ad22d453/serve-fd-positional-read

Conversation

@robobun

@robobun robobun commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A fetch handler returns new Response(Bun.file(fd)) for a descriptor that the program wrote through. The response is Content-Length: 16 and 0 bytes. Range: bytes=0-3 answers 206 with the bytes at the position.
  • FileResponseStream::start (src/runtime/server/FileResponseStream.rs:245) reads at the offset only when offset > 0. At offset 0 it calls read(2), which starts at the position of the descriptor and moves it.
  • Regression from serve: consolidate file streaming into FileResponseStream; Range support; fix aborted-stream hang #29202 (v1.3.13) on plain HTTP. No user reported it.

Fix

  • do_sendfile sets a new StartOptions.positional for a regular Bun.file(fd), and start then reads at the offset, also at 0.
  • On Windows libuv moves the position to read at an offset, so there start reads through a second handle.
  • Correct because the head comes from fstat and the sendfile(2) backend already sends from the offset.
  • Verified: 7 new tests in test/js/bun/http/bun-serve-file.test.ts fail on main. Self-reviewed: 30 concerns raised, 28 addressed.

Background

  • FileResponseStream sends a Bun.serve file body: sendfile(2) for plain TCP on Linux from 1 MiB, a BufferedReader otherwise.
  • Considered pread for every regular file (2 SetFilePointerEx per read on Windows) and a head that follows the position (Notes).

Downsides

Notes

Where this comes from. #44826 (found by a fuzzer of Bun.serve file responses) lists this as a limit it leaves open. #33873 made the same FileResponseStream change in July, together with a change to Bun.file(fd).text(). The stale bot closed it with no review. This PR takes only the Bun.serve part and sets the flag only for a descriptor that the server did not open. A program on a released version can pass Bun.file(path).

Who reaches it. A handler that returns Bun.file(fd) for a regular file when the descriptor is not at byte 0: the program wrote or read through it, it is served a second time, or it is Bun.stdin from a file. It needs the reader backend: under 1 MiB on Linux plain HTTP, every size over TLS, on macOS and on Windows.

Observed (Linux x64, plain HTTP, one 16-byte file 0123456789ABCDEF). v1.3.12 and v1.3.13 are the official binaries. main is a release build of 3d6c7bc, and the last column is the same commit with this change.

handler, client v1.3.12 v1.3.13 main this PR
Bun.file(fd), descriptor written through, fetch() 200, whole file ECONNRESET ECONNRESET 200, whole file
the same, curl exit 0 exit 18, 0 bytes exit 18, 0 bytes exit 0
Bun.file(fd) at byte 3, Range: bytes=0-3 200, whole file (no Range support) 206, bytes 0-3/16, 3456 the same 206, bytes 0-3/16, 0123
Bun.file(fd).slice(0, 4) at byte 3, curl exit 18, 0123 exit 18, empty exit 18, 789A exit 18, 0123
the same, fetch() ECONNRESET ECONNRESET ECONNRESET ECONNRESET

The failing fetch() and curl cells wait for the idle timeout first. More rows, main against this PR, with a raw client that sends Connection: close:

probe main this PR
slice(0, 4) three times on one descriptor 0123, 4567, 89AB 0123, 0123, 0123
position of the descriptor after each 4, 8, 12 0, 0, 0
Bun.file(fd) twice whole file, then 0 bytes whole file twice
16 MiB over TLS, two responses in flight 8,388,608 bytes each 16,777,216 bytes each
1 MiB over TLS, descriptor at byte 3 1,048,573 bytes 1,048,576 bytes
1 MiB over plain TCP (sendfile) 1,048,576 bytes 1,048,576 bytes
position after an aborted 300 KiB response 307200 0
new Response(Bun.stdin), a 19-byte file on stdin, two requests 19 bytes, then 0 bytes 19 bytes twice
the same after the shell read a 7-byte line from stdin 12 bytes under Content-Length: 19, then 0 bytes 19 bytes twice

What is a regression. Only the whole-file body on plain HTTP on POSIX: v1.3.12 sent it with sendfile(2) at any size. Over TLS v1.3.12 read the file into memory from the position and framed what it read: a descriptor at byte 3 got Content-Length: 13 and 13 bytes. v1.3.13 and main send Content-Length: 16 and those 13 bytes. This PR sends all 16, so for TLS it defines the behavior and does not restore it. Range support arrived with #29202, so the Range row is a defect of that support. A slice(0, n) of a descriptor was mis-framed in v1.3.12 and still is.

Why a slice is a byte range of the file. BunFile.slice documents end as "absolute offset in bytes (relative to 0)" (packages/bun-types/bun.d.ts:2684). docs/runtime/http/routing.mdx:248 says "To send part of a file, use the slice(start, end) method" with start = 0. The test "Range header cannot escape a Bun.file().slice(0, n) window via fetch handler" pins the slice as a window for a path. For a whole file no text says what the position means. That is the decision in Downsides.

Considered.

  • A head that follows the position (Content-Length = size minus position). A program has no API to put a descriptor back at byte 0, so one descriptor could serve one response. Two responses in flight share the position: the 16 MiB row above. A Range reply and the sendfile(2) backend need absolute bytes in any case.
  • Sending a Bun.file(fd) body down the stream path, which clone() and tee() already take and which reads at the offset on main. It gives up sendfile(2) and Range, and Bun.stdin.stream(), process.stdin: read an unsliced file from its current offset #41687 changes that path to the position.
  • pread for every regular file. It changes the syscalls of static routes (serve-file-slice-read-error.test.ts counts 3 read calls) and costs 2 SetFilePointerEx per read on Windows.

With #41687. On main a handler that returns a response after clone(), a tee() branch, pipeThrough() or HTMLRewriter.transform() over the same Bun.file(fd) already sends the bytes from byte 0, because the server then reads a stream of the file. The new table test has those rows beside the rows that this PR fixes, so the two readers must agree. #41687 makes that stream read from the position when the offset is 0. If both PRs land as they are, return response and the same response after clone() send different bytes. The decision in Downsides settles which one changes.

With #44826. The diffs do not overlap in src/. If #44826 lands alone, a Bun.file(fd) slice at byte 0 turns from a failed response into a well-framed one with the wrong bytes (Content-Length: 4 over 4567). So this PR lands first or together. The test helper wireResponse here is the one from #44826, byte for byte, so the test files merge. The PR that lands second adds the Content-Length assertion for a Bun.file(fd) slice.

Measurements (release builds, base 3d6c7bc, Linux x64).

  • Syscalls on the served file per 10 responses of a 4 KiB file (ptrace counter): static route, Bun.file(path) and Bun.file(path).slice(0, n): read 10, pread64 0, fstat 10 on both builds. Bun.file(fd): read 10 with 1 of 10 bodies complete on main, pread64 10 with 10 of 10 complete here.
  • Instructions (objdump of the symbols): FileResponseStream::start 288 to 290, nothing per read. do_sendfile 967 to 971, FileRoute::on 775 to 777, DirectoryRoute::on 1440 to 1439.
  • .text 80,509,029 to 80,509,285 bytes on Linux x64. The stripped binary has the same file size. The function that only Windows compiles is not in these numbers.
  • size_of::<StartOptions>() 96 to 104 bytes.
  • Windows, by code and not measured: a Bun.file(fd) body costs one path lookup, one open, two fstat and one close per response, and its reads have no offset. A static route pays nothing. Process Monitor on Windows can measure it.

Who reads what on main (a Bun.file(fd) of a regular file).

consumer POSIX Windows
.text(), .bytes(), .arrayBuffer(), .json(), offset 0 from the position, moves it at offset 0 (ReadFileUV)
the same for slice(a, b) with a > 0 lseek to a, then read: right bytes, moves the position seek, then reads at the offset
.stream() at the offset, position not moved at the offset
.stream() with #41687 as it stands, offset 0 from the position from the position
Bun.serve body, offset 0, main from the position (sendfile from 1 MiB: at the offset) from the position
Bun.serve body, this PR at the offset (pread), position not moved through a second handle, the handle of the program is not read

The Windows column is from the code. I ran nothing on Windows or macOS myself. The new tests pass on the Windows and macOS lanes of CI build 126929.

Same idiom, not changed here.

Limits.

  • A descriptor that is not a regular file (block device, eventfd) keeps read(2) at offset 0. No test pins that half of the flag: the response of an eventfd never ends on main.
  • Windows: libuv reads at an offset by saving the position of the handle, reading, and restoring it, on a pool thread. A readSync or writeSync by the program at that moment can land at the wrong offset. So start opens the file again by its path (get_fd_path), checks that it is the same file (volume and file index), and reads through that handle. That also covers a slice(a, b) body with a > 0, which had this exposure on main. If the file does not open again (no shared read access, or its name is gone), the read falls back to the libuv read at the offset. A region that the program locked with LockFile cannot be read through the second handle.
  • A regular file that refuses pread (FUSE with FOPEN_NONSEEKABLE) now fails with ESPIPE at offset 0, where read(2) worked. By code, not run. .stream() and a slice(a, b) body with a > 0 already read such a file with pread on main.
  • macOS and the BSDs: open("/dev/fd/N") duplicates descriptor N (fd(4)), so Bun.file("/dev/fd/N") shares the position of N. Not covered, not run.
  • Bun.serve: fall back to read+write when sendfile(2) refuses the file #41474 and Remove libuv on Windows #42819 change the same statement, and Bun.serve: read a regular file whose st_size is 0 to its end #43974 the same struct. The fallback arm of Bun.serve: fall back to read+write when sendfile(2) refuses the file #41474 needs positional too.

Self-review. Two of the 30 concerns are taken in part. The tests stay in their own block and do not move into the table that #44826 adds, because that table is not on main. The stream rows are a response after clone(), tee(), pipeThrough() and HTMLRewriter. A row that returns the clone itself is left out: the type of Response.clone() in the test setup is not the type a handler returns.

After review. The first version read through libuv at the offset on Windows too. A review showed that a write by the program during a response could then land at the wrong offset, so Windows got the second handle. The stream rows of the table test now use fetch(), because on Windows the HTMLRewriter response is chunked. The Windows code is type-checked for x86_64-pc-windows-msvc, and CI build 126929 passed on every lane with it (181 jobs).

Suites run on a debug ASAN build: bun-serve-file.test.ts, serve-file-slice-read-error.test.ts (still 3 read calls), bun-file-fd-read.test.ts, bun-stdin-slice.test.ts (with a 60 s timeout: its tests take 5 to 10 s on that build), body-clone.test.ts, bun-serve-static.test.ts, test/regression/issue/20965.test.ts. The 7 new tests also fail on a release build of main and pass on a release build of this PR.


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-serve-file.test.ts

…tor's position

A fetch handler can return Bun.file(fd) for a regular file on a descriptor
that the program opened. FileResponseStream read such a body with read(2)
when the byte range started at offset 0, so the body started at the position
of the descriptor and each response moved it. A descriptor that the program
had written through sent Content-Length: 16 and no bytes. A Range request
for bytes 0-3 sent the bytes at the position.

StartOptions now has a positional flag. do_sendfile sets it for a regular
file from Bun.file(fd), and start() then reads at the offset, also when the
offset is 0. Static routes, directory routes and Bun.file(path) open a new
descriptor for each response and keep read(2).
@robobun

robobun commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:12 PM PT - Oct 16th, 2026

✅ @robobun, your commit 13c61d496db124cae23391210e056a21b5d6f521 passed in Build #126929! 🎉


🧪   To try this PR locally:

bunx bun-pr 45266

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

bun-45266 --bun

@robobun

robobun commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI passed on every lane (build 126929, 181 jobs), Windows and macOS included.

Reproduction (Linux x64, release build of main 3d6c7bc): a fetch handler returns new Response(Bun.file(fd)) for a 16-byte file. The program wrote the file through fd with fs.writeSync, so the descriptor is at the end of the file. The first response is Content-Length: 16 with 0 bytes (curl exits 18). With a descriptor at byte 3, Range: bytes=0-3 answers 206, bytes 0-3/16 and the body 3456. The official v1.3.12 binary sends the whole file.

The 7 new tests in test/js/bun/http/bun-serve-file.test.ts fail on that build and pass on this branch.

For a maintainer: the PR takes one decision that nobody made yet. In Bun.serve a Bun.file(fd) body is an absolute byte range, Bun.stdin included. #41687 proposes the opposite for .stream(). This PR should land before #44826 or together with it.

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in 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: Advanced
  • Run ID: bd98206a-83d3-4480-96f3-2d9cf752d792
📥 Commits

Reviewing files that changed from the base of the PR and between f1f64c6 and 9a5afc4.

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

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

The change adds explicit positional-read control to FileResponseStream. Internally opened regular files use positional reads, while directory and file routes explicitly disable them. Tests cover descriptor-backed responses, offsets, concurrency, and TCP/TLS behavior.

Changes

File response reads

Layer / File(s) Summary
Positional read behavior
src/runtime/server/FileResponseStream.rs
StartOptions adds positional. When enabled, FileResponseStream::start uses the supplied offset, including zero.
Server path configuration
src/runtime/server/RequestContext.rs, src/runtime/server/DirectoryRoute.rs, src/runtime/server/FileRoute.rs
do_sendfile enables positional reads for internally opened regular files. Directory and file routes explicitly disable positional reads.
Descriptor response validation
test/js/bun/http/bun-serve-file.test.ts
Tests cover descriptor-position independence, full, range, and slice responses, response shapes, concurrent responses, and TLS and plain TCP. A shared helper handles raw response inspection.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 9a5af

This change adds tests for serving file descriptors and a helper that reads raw responses. No actionable merge risk was identified in the supplied material.

Pre-merge checks: Passed 4
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 and concisely identifies the main change: Bun.serve reads Bun.file(fd) bodies from the requested offset instead of the descriptor's current position.
Description check Passed The description is detailed and covers the problem, fix, verification, scope, limitations, and related issues. It does not use the template headings exactly, but it provides the required information a…
Full details: Description check

Explanation

The description is detailed and covers the problem, fix, verification, scope, limitations, and related issues. It does not use the template headings exactly, but it provides the required information and is substantially complete.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread src/runtime/server/FileResponseStream.rs
Comment thread test/js/bun/http/bun-serve-file.test.ts
Comment thread test/js/bun/http/bun-serve-file.test.ts
A response whose body is a stream of the file can be chunked. On Windows
the HTMLRewriter row is. The raw client returned the chunk framing as the
body. The rows where the server reads a stream now use fetch(), which
accepts both framings. A connection that closes before the first chunk
fails the in-flight test at once.
Comment thread src/runtime/server/FileResponseStream.rs Outdated

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

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (3):

  • 🔴 src/runtime/server/FileResponseStream.rs:115 — Windows programs that keep writing through a fd while serving it as Bun.file(fd) can now have those writes silently ove…
  • Also unresolved: 2 minor or pre-existing.

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

libuv reads at an offset by saving the position of the handle, reading,
and restoring it, on a pool thread. A read or write that the program makes
through the descriptor at that moment can land at the wrong offset. On
Windows a positional file is now opened again by its path, and the
response reads through that handle. The read at an offset on the
program's handle stays as the fallback when the file does not open again.

The test with two responses in flight now checks the position of the
descriptor on every platform.
Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread src/runtime/server/FileResponseStream.rs Outdated

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

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

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