Bun.serve: stop FIFO file responses from keeping the process alive

Closed
RO
2mo ago
+269-49

What

A Response(Bun.file(fifo)) body whose read is parked on a poll that will never fire keeps the bun process alive forever. After the client aborts and server.stop(true) runs, the process never exits:

import fs from "node:fs";
import { execSync } from "node:child_process";
const fifo = `/tmp/f-${process.pid}`;
execSync(`mkfifo ${fifo}`);
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() { return new Response(Bun.file(fifo)); } });
try { await fetch(`http://127.0.0.1:${server.port}/`, { signal: AbortSignal.timeout(1500) }); } catch (e) { console.log(e.name); }
server.stop(true);
fs.unlinkSync(fifo);
// prints TimeoutError, then hangs forever

While reproducing this I found the success path leaks too: a FIFO response that completes normally at EOF also leaves the process unable to exit (body delivered, server.stop(true) returns, event loop never goes idle).

Cause

Two leaks in FileResponseStream, one per scenario:

  1. start() takes a ref for the in-flight read (hold_read_ref), released only by take_read_ref() in on_reader_done / on_reader_error / the backpressure arm of on_read_chunk. When the client aborts while the read is parked on a poll that will never fire (FIFO with no writer), none of those ever run, so the stream's refcount never reaches zero: Drop never runs, the fd leaks, and the registered FilePoll keeps the event loop referenced for the life of the process.

  2. start() clears the reader's CLOSE_HANDLE flag because auto_close owns the fd, and the posix PosixBufferedReader teardown (close_without_reporting, reached from its Drop) skips the handle entirely in that mode. The armed FilePoll, which holds the event loop's active ref, is never unregistered or freed even when the refcount does reach zero, which is why the clean-EOF case hangs as well.

Fix

  • finish() now releases the in-flight read ref if it is still held: once the stream is finished, no reader callback is coming to adopt it. This is a no-op on the reader-callback paths, which already took the ref, and every entry point into finish() holds its own guard ref, so the free still lands on that guard's drop.
  • Drop now unregisters and frees the FilePoll explicitly without closing the fd (which auto_close still owns). Unix only; on Windows the reader's own Drop already hands its libuv source back to the loop. This unregister-without-closing-the-fd teardown already existed hand-rolled in the shell IOReader/IOWriter (the other CLOSE_HANDLE-cleared owners), so it is now a named PollOrFd::release_poll_keep_fd() in bun_io and all five sites use it.

Windows follow-up

The first CI round (build 89813) segfaulted in serve.test.ts on Windows x64 with heap corruption:

panic(main thread): Segmentation fault at address 0xFFFFFFFFFFFFFFFF
mi_page_malloc_zero <- Vec::reserve <- WindowsBufferedReader::get_read_buffer_with_stable_memory_address <- FileResponseStream::start

Releasing the read ref means an aborted stream can now actually be freed while its uv_fs_read is still running on the libuv threadpool (before this PR it just leaked, so the window was unreachable). uv_cancel cannot stop an op that has already started, and the op's iov points into the reader's _buffer, so the read completed into freed memory and corrupted the allocator freelist; the next same-size allocation (another response's reader buffer) crashed. WindowsBufferedReader::close_impl now moves _buffer into the detached File box when an op is in flight, and the fs callback reclaims both together after the op completes.

Verification

Three new tests in test/js/bun/http/bun-serve-file.test.ts, each spawning a fixture process that must exit on its own:

  • abort via fetch() handler (posix): client reads the first body chunk (proving the server's read is parked on its poll), aborts, server.stop(true), process must exit 0. The fixture deliberately never closes its FIFO writer: an explicit close would deliver an EOF that releases the parked read through the reader-done path and mask a missing abort-time release, so the test isolates both halves of the fix.
  • abort via a static route (posix): same scenario through routes:, the FileRoute entry point, which had the identical hang.
  • EOF (Linux only): writer closes after the first chunk is read, response completes, server.stop(true), process must exit 0. Restricted to Linux because macOS kqueue does not reliably wake an armed FIFO read filter when the last writer closes (the workaround note in io/pipes.rs documents this), so the late EOF there arrives via idleTimeout instead of the poll.

All three hang (test timeout, dangling process killed) on bun 1.4.0 without the fix and pass with it. Full bun-serve-file.test.ts, bun-serve-static.test.ts, serve-file-slice-read-error.test.ts, and tls-bunfile-leak.test.ts suites pass on Linux, and the shell file-io.test.ts / shell-blocking-pipe.test.ts suites pass with the release_poll_keep_fd migration; on Windows, serve.test.ts and bun-serve-file.test.ts pass and a 1000-iteration abort-mid-file-response stress runs clean with the buffer hand-off fix. serve.test.ts on Linux has 4 pre-existing environment failures (IPv6, privileged ports) that fail identically on the released bun.

Related but distinct: #37082 fixes the wire framing of the late-EOF completion in this same function (end_without_body leaving a chunked body unterminated). This PR does not touch framing; the new EOF test tolerates either framing so the two do not depend on each other.


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