Skip to content

Bun.serve: close the file reader when a response is aborted on Windows - #46574

Draft
robobun wants to merge 9 commits into
mainfrom
robobun/be1fe131/windows-file-response-disconnect
Draft

robobun wants to merge 9 commits into
mainfrom
robobun/be1fe131/windows-file-response-disconnect

Conversation

@robobun

@robobun robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On Windows, a Bun.file response reads its file to the end after an abort (client disconnect, idle timeout, server.stop(true)): 256 reads for 16 MiB.
  • A Bun.stdin body stays open, and the next Response(Bun.stdin) gets "".
  • Cause: finish() (src/runtime/server/FileResponseStream.rs:581) releases the read ref only on POSIX. The Windows reader issues uv_fs_read until EOF (src/io/PipeReader.rs:1563).

Fix

  • On Windows, on_aborted calls reader.close(). It cancels the read in flight, or waits for it. on_reader_done releases the ref, and the stream closes the fd.
  • Correct: close() is the reader's call for a consumer that left, as in FileReader::on_cancel.
  • Verified on Windows CI: five tests in bun-serve-file.test.ts and terminal-spawn.test.ts. Reads after the abort: 0 with the read queued, 1 with it running (main 256).
  • Self-reviewed: 12 concerns raised, 10 addressed. Notes lists the rest.

Background

  • FileResponseStream streams an open fd into a response. On Windows, libuv reads a file with one uv_fs_read at a time on its thread pool.
  • The stream holds one ref for the read in flight. On Windows only a reader callback releases it.
  • Considered Bun.serve: release the FIFO file reader when the client disconnects #41874, where the reader owns the fd: it moves the fd close of every response. This change adds nothing without an abort.

Downsides

  • Bun.file(fd) of a pipe with fd > 2: libuv closes the caller's fd with the reader. main does that at the pipe's end, this change also at the abort.
  • Cost without an abort: none. linux-x64 release binaries differ only in the build ID, bun.exe is +0.0 KB.
  • No sanitizer run: Windows CI has none.
Notes

State of this draft: build 131449 runs the head. The numbers below for the fix are from builds 131318 and 131351, before the tests were reworked. I add the numbers of build 131449 when it ends.

Without the fix (build 131437: FileResponseStream.rs of main plus the tests of this PR, Windows 2019 x64 and Windows 11 aarch64, 4 of 4 attempts on each lane)

  • aborted Response(Bun.file) does not run the read it has queued: 256 reads after the abort. The test expects 0.
  • aborted Response(Bun.file) stops after the read that is running: 256 reads. The test expects 1.
  • Response(Bun.file) stops reading its file when server.stop(true) aborts it mid-body: 239 or 240 reads after the stop in each of 4 rounds. The test expects fewer than 8.
  • the next response can read stdin after an aborted Response(Bun.stdin): "". The test expects "world".
  • the next response can read the console after an aborted Response(Bun.stdin) (terminal-spawn.test.ts): the second response is reset (ECONNRESET).

With the fix (builds 131318 and 131351, same lanes, earlier version of the tests)

  • Reads after an abort that found the first read queued: 0 on both lanes (main 256). The fd slot is released.
  • Second Response(Bun.stdin) after an aborted one: 5 of 5 bytes (main 0).
  • Reads for one complete 16 MiB response: 256 (main 256).
  • Response(Bun.file) does not double-close the fd on Windows passes on both lanes.
  • Bun.file(3) of a pipe of the caller after an aborted response: fstat gives EBADF on both lanes (a temporary test, not in this PR). On main the fd stays open until the writer closes the pipe. That is from the code, I did not run it.
  • Build 131351 passed on every lane (181 of 181 jobs).

Cost for a response that is not aborted

  • linux-x64: two release builds, one with the FileResponseStream.rs of main and one with this PR. All 19 FileResponseStream symbols have the same size, .text is 82,231,705 B in both, and the stripped binaries differ only in the 20 bytes of .note.gnu.build-id.
  • bun.exe: +0.0 KB on x64 and on arm64 (CI binary-size step of build 131318 against build 131216, resolution 0.1 KB). The compressed artifacts differ by 208 B (x64) and 53 B (arm64).
  • Not measured: a diff of each symbol on Windows. The bun.exe and .pdb artifacts are on a host that this machine cannot reach.

How the tests work

  • process.resourceUsage().fsRead is the read operation count of the process on Windows (GetProcessIoCounters).
  • The queued and the running test use one pool thread (UV_THREADPOOL_SIZE=1) and park it in a read of an empty stdin pipe. The first read of the response is then still queued when the client leaves. The running test frees the pool from the abort listener, which runs before the server closes the reader, and waits there until the read count moves.
  • The mid-body test calls server.stop(true) from the data callback of a client that has 1 MB of the body. The server is not in a callback of its own at that moment, so a read is in flight. It runs 4 rounds, for a fetch handler and a static route.
  • The stdin test runs on every platform. On POSIX it covers a socket body.

When the bug shows on main: the server must handle the abort while a uv_fs_read is queued or running. If the read completes first and the write then fails, the stream pauses the reader and releases the ref, and the abort frees the stream at once. With client resets the bad state came up in 1 of 8 attempts on an idle pool (build 131200, 24 resets each) and in 4 of 10 runs on a busy pool (build 131216). That is why the tests do not use a reset race.

Cost of one hit on main: for a regular file, (remaining bytes / 64 KiB) reads on the pool with the fd open. For a pipe or a console there is no bound: the reader stays armed, keeps the event loop alive and takes later input.

History

Limits

  • The abort listener of req.signal runs before the reader closes. A handler that answers the next request with the same fd from inside that listener still finds the old reader. POSIX has the same order.
  • A console body: libuv cancels a parked line read by an Enter key event that it writes into the console and then discards, as for every uv_read_stop on a console.
  • Bun.file(fd) of a pipe with fd > 2: libuv closes the fd when the reader closes (src/io/PipeReader.rs:1773). main has this for every completed response. A fix duplicates the handle in Source::open, as libuv does for fd 0 to 2. No issue tracks it.
  • A named pipe opened by path is closed by libuv and again by the stream. main has this too.

Not in this PR

Also considered

  • Release the ref and pause in on_read_chunk when the response is done. The queued read still runs, and a pipe body still loses the next chunk of the writer.
  • The same close() in finish(). It has the same effect, and it adds a call to every response.
  • Honor the false of the parent in the shared Windows reader. That changes a contract that 14 kinds of parent use.

Linux: bun bd test test/js/bun/http/bun-serve-file.test.ts passes (177 pass, 4 skip) and bun bd test test/js/bun/terminal/terminal-spawn.test.ts passes (16 pass, 3 skip). bun run rust:check-all passes for x86_64-pc-windows-msvc and aarch64-pc-windows-msvc.


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/terminal/terminal-spawn.test.ts, test/js/bun/http/bun-serve-file.test.ts

…nects

A regular file that is read while the client leaves, at the first read
and in the middle of the body, and a pipe body (Bun.stdin). The fixtures
count the reads of the file with process.resourceUsage().fsRead.
@robobun

robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. The fix is back on the head (a40d82e), and build 131449 runs it with the reworked tests.

  • I cannot run Windows locally. CI is the only Windows machine I have, so each claim about Windows names the build it comes from.
  • Without the fix, build 131437 (FileResponseStream.rs of main plus the tests of this PR): five tests fail on Windows 2019 x64 and on Windows 11 aarch64, in 4 of 4 attempts on each lane.
    • aborted Response(Bun.file) does not run the read it has queued: 256 reads after the abort, which is the whole 16 MiB file. The test expects 0.
    • aborted Response(Bun.file) stops after the read that is running: 256 reads. The test expects 1.
    • Response(Bun.file) stops reading its file when server.stop(true) aborts it mid-body: 239 or 240 reads after the stop in each round. The test expects fewer than 8.
    • the next response can read stdin after an aborted Response(Bun.stdin): the next response gets "". The test expects "world".
    • the next response can read the console after an aborted Response(Bun.stdin): the next response is reset.
  • With the fix and an earlier version of the tests, build 131351 passed on every lane. Build 131318 shows the numbers: 0 reads after the abort, and the next response gets "world".

@robobun

robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:44 PM PT - Oct 23rd, 2026

✅ @robobun, your commit a40d82e3c0ef13690d6fe887bfd780bb41dfc8d6 passed in Build #131449! 🎉


🧪   To try this PR locally:

bunx bun-pr 46574

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

bun-46574 --bun

…t [skip builds]

On main the clients of this test never left while a read of the response
was queued or running, so the test passed there. The fixture now runs
with one pool thread and keeps it busy with open and close jobs while a
client takes the response.
On Windows, FileResponseStream::finish() keeps the ref of the read in
flight and nothing stops the reader. After a disconnect a regular file
was read to its end, and the fd stayed open for that time. A pipe body
stayed open until its writer closed it, and the dead response took the
bytes of the pipe.

on_aborted now calls reader.close(). That cancels the uv_fs_read in
flight, or waits for it, and then calls on_reader_done, which releases
the ref. The stream closes the fd after that, as before.
Two of the new tests fail on purpose at their last line, and a fourth
test prints the state of a pipe fd of the caller. A later commit removes
this before the PR is ready.
@robobun robobun changed the title Bun.serve: stop reading a Bun.file response on Windows when the client disconnects Bun.serve: close the file reader when the client disconnects on Windows Oct 23, 2026
Build 131318 has the numbers. The only other change is a comment: the
read of stdin is not in the read count.
…sole body

- The parked-pool fixture has a second mode. It frees the pool from the
  abort listener, so the read of the response is already running when the
  server closes the reader.
- The mid-body test no longer needs a client reset to arrive at the right
  moment. It calls server.stop(true) from the data callback of the client,
  for a fetch handler and for a static route.
- The stdin test runs on every platform.
- A Windows test serves Bun.stdin over a console.
The tests of the previous commit have not run against the code of main
yet. This commit restores FileResponseStream.rs of main so that CI shows
them without the fix. The next commit puts the fix back.
Build 131437 ran the tests against FileResponseStream.rs of main. On
Windows 2019 x64 and Windows 11 aarch64 the four file and stdin tests
and the console test failed in every attempt.
@robobun robobun changed the title Bun.serve: close the file reader when the client disconnects on Windows Bun.serve: close the file reader when a response is aborted on Windows Oct 23, 2026

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