From 6e69e3b8a9206caf789c5eb297dda4571b2908fc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:10:15 +0000 Subject: [PATCH 1/4] Bun.serve: serve character-device Bun.file() bodies instead of closing with zero bytes On Linux, returning new Response(Bun.file("/dev/null")) (or any character device whose file_operations lack .poll, such as /dev/zero or /dev/full) from a Bun.serve fetch handler closed the connection with no status line, no headers, and no error() callback. strace showed epoll_ctl(EPOLL_CTL_ADD, chardev-fd) returning EPERM followed by a pre-header teardown. do_sendfile classifies S_ISCHR as (FileType::Pipe, pollable = true) and hands the fd to FileResponseStream, whose BufferedReader tries to register it with the main event loop's epoll. For /dev/null-class devices epoll_ctl returns EPERM; PosixBufferedReader::register_poll dispatched that through on_reader_error, which FileResponseStream's fail_with handled by force-closing the socket and discarding any corked headers. The handler's error() callback is never consulted because on_file_stream_error only cleans up after the socket is already gone. PosixBufferedReader::start now recovers from an EPERM on the initial registration by dropping the never-registered FilePoll and continuing on the non-pollable (blocking-read) path, the same fallback IOWriter already uses for EPERM/EINVAL on the writer side. The character devices epoll rejects are exactly those with no .poll hook, which the kernel treats as always-readable, so the non-pollable read loop drives them to EOF without needing readiness notifications. For the serve path itself, RequestContext::do_sendfile no longer writes Content-Length for non-regular files (the stat size is meaningless there) and passes the user's .slice() length through to FileResponseStream so a sliced /dev/zero stops after that many bytes instead of streaming forever. FileResponseStream::finish now ends an immediately-EOF stream with resp.end("") rather than end_without_body(), so uWS supplies the missing framing and the client is not left waiting on a headerless body. --- src/io/PipeReader.rs | 43 +++++++++--- src/runtime/server/FileResponseStream.rs | 7 +- src/runtime/server/RequestContext.rs | 9 ++- test/js/bun/http/bun-serve-file.test.ts | 85 ++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 13 deletions(-) diff --git a/src/io/PipeReader.rs b/src/io/PipeReader.rs index 2dd493599b90..9d9e02142d7b 100644 --- a/src/io/PipeReader.rs +++ b/src/io/PipeReader.rs @@ -402,6 +402,18 @@ impl PosixBufferedReader { /// embedding `self` (the shell `PipeReader` does exactly that), so the /// caller must not touch `self` again after a `false` return. pub fn register_poll(&mut self) -> bool { + match self.try_register_poll() { + sys::Result::Ok(()) => true, + sys::Result::Err(err) => { + self.vtable.on_reader_error(err); + false + } + } + } + + /// Like [`register_poll`] but returns the registration error instead of + /// dispatching it through `on_reader_error`, so the caller can recover. + fn try_register_poll(&mut self) -> sys::Result<()> { // Hoist vtable-derived scalars and // normalize self.handle to Poll before taking the single &mut borrow, // so no raw-pointer escape is needed. @@ -411,7 +423,7 @@ impl PosixBufferedReader { if let PollOrFd::Fd(fd) = self.handle { if !self.flags.contains(PosixFlags::POLLABLE) { - return true; + return sys::Result::Ok(()); } self.handle = PollOrFd::Poll(FilePollRef::init( ev, @@ -420,7 +432,7 @@ impl PosixBufferedReader { )); } let Some(poll) = self.handle.get_poll_mut() else { - return true; + return sys::Result::Ok(()); }; poll.set_owner(Owner::new(PollTag::BufferedReader, owner_ptr.cast())); @@ -428,13 +440,7 @@ impl PosixBufferedReader { poll.enable_keeping_process_alive(ev); } - match poll.register_with_fd(lp.cast(), FilePollKind::Readable, poll.fd()) { - sys::Result::Err(err) => { - self.vtable.on_reader_error(err); - false - } - sys::Result::Ok(()) => true, - } + poll.register_with_fd(lp.cast(), FilePollKind::Readable, poll.fd()) } pub fn start(&mut self, fd: Fd, is_pollable: bool) -> sys::Result<()> { @@ -449,7 +455,24 @@ impl PosixBufferedReader { if self.get_fd() != fd { self.handle = PollOrFd::Fd(fd); } - self.register_poll(); + if let sys::Result::Err(err) = self.try_register_poll() { + // On Linux, epoll_ctl(EPOLL_CTL_ADD) returns EPERM for fds whose + // file_operations lack .poll (e.g. /dev/null, /dev/zero). Such fds + // are always-readable, so fall back to the non-pollable path + // instead of tearing the reader down. Mirrors IOWriter::__start. + #[cfg(any(target_os = "linux", target_os = "android"))] + if err.get_errno() == sys::E::EPERM { + self.flags + .remove(PosixFlags::POLLABLE | PosixFlags::NONBLOCKING); + if matches!(self.handle, PollOrFd::Poll(_)) { + self.handle + .close_impl(None, None::, false); + } + self.handle = PollOrFd::Fd(fd); + return sys::Result::Ok(()); + } + self.vtable.on_reader_error(err); + } sys::Result::Ok(()) } diff --git a/src/runtime/server/FileResponseStream.rs b/src/runtime/server/FileResponseStream.rs index b77f829c93ea..193a888094c8 100644 --- a/src/runtime/server/FileResponseStream.rs +++ b/src/runtime/server/FileResponseStream.rs @@ -496,8 +496,11 @@ impl FileResponseStream { if !self.state.contains(State::RESPONSE_DONE) { self.state.insert(State::RESPONSE_DONE); self.detach_resp(); - self.resp - .end_without_body(self.resp.should_close_connection()); + // `end` (not `end_without_body`): when no Content-Length has been + // written yet (non-regular files) uWS supplies the framing here, + // so an immediately-EOF fd like /dev/null still produces a valid + // empty response instead of leaving the client waiting. + self.resp.end(b"", self.resp.should_close_connection()); (self.on_complete)(self.ctx, self.resp); } diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 43a8b24f750a..dd017b541609 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1835,7 +1835,10 @@ where }); } - self.flags.set_needs_content_length(true); + // Non-regular files (FIFOs, character devices, sockets) have no + // meaningful stat size, so the body length is unknown up front and + // must be framed with chunked encoding. + self.flags.set_needs_content_length(is_regular); let blob_offset = match &self.blob { AnyBlob::Blob(b) => b.offset.get(), _ => unreachable!(), @@ -1972,6 +1975,10 @@ where offset: self.sendfile.offset as u64, length: if is_regular { Some(self.sendfile.remain as u64) + } else if original_size != crate::webcore::blob::MAX_SIZE { + // An explicit .slice() on a non-regular file caps the body at + // that many bytes; without it, read until EOF. + Some(original_size as u64) } else { None }, diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 6e18e4644cad..33f7358ca6ba 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, mock, test } from "bun:test" import { bunEnv, bunExe, isASAN, isWindows, rmScope, tempDir, tempDirWithFiles } from "harness"; import { mkfifo } from "mkfifo"; import { unlinkSync } from "node:fs"; +import * as net from "node:net"; import { join } from "node:path"; const LARGE_SIZE = 1024 * 1024 * 8; @@ -1138,3 +1139,87 @@ console.log("OK"); }, 60_000, ); + +// On Linux, epoll_ctl(EPOLL_CTL_ADD) on /dev/null-class character devices +// returns EPERM (their file_operations have no .poll). The file response +// stream used to treat that as a fatal reader error and force-close the +// connection before any status line or header byte was written, without ever +// invoking the error() callback. +describe.skipIf(isWindows)("serving a character-device Bun.file from fetch()", () => { + // Use a raw TCP client so we can observe the zero-byte close that fetch() + // would otherwise report as a generic connection error. + async function rawGet(port: number, path: string): Promise { + const { promise, resolve } = Promise.withResolvers(); + let acc = Buffer.alloc(0); + const s = net.connect(port, "127.0.0.1", () => { + s.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); + }); + s.on("data", d => { + acc = Buffer.concat([acc, d]); + }); + // The pre-fix force_close sends RST, so swallow ECONNRESET and report + // whatever bytes arrived; the assertion on the status line catches the + // zero-byte case. + s.on("error", () => {}); + s.on("close", () => resolve(acc)); + await promise; + return acc; + } + + test("/dev/null serves an empty 200 response", async () => { + let errorArg: unknown = undefined; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + return new Response(Bun.file("/dev/null")); + }, + error(e) { + errorArg = e; + return new Response("ERR", { status: 500 }); + }, + }); + + const raw = await rawGet(server.port, "/"); + const head = raw.toString("latin1").split("\r\n\r\n")[0]; + expect(head.split("\r\n")[0]).toBe("HTTP/1.1 200 OK"); + expect(errorArg).toBeUndefined(); + + const res = await fetch(server.url); + expect({ + status: res.status, + body: await res.text(), + error: errorArg, + }).toEqual({ status: 200, body: "", error: undefined }); + }); + + test("/dev/zero with .slice() serves the sliced length", async () => { + const len = 4096; + let errorArg: unknown = undefined; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + return new Response(Bun.file("/dev/zero").slice(0, len)); + }, + error(e) { + errorArg = e; + return new Response("ERR", { status: 500 }); + }, + }); + + const raw = await rawGet(server.port, "/"); + // The pre-fix behavior was a zero-byte close; after the fix we must at + // least receive a status line. + expect(raw.toString("latin1", 0, 15)).toBe("HTTP/1.1 200 OK"); + + const res = await fetch(server.url); + const body = new Uint8Array(await res.arrayBuffer()); + expect({ + status: res.status, + bodyLength: body.length, + allZero: body.every(b => b === 0), + error: errorArg, + }).toEqual({ status: 200, bodyLength: len, allZero: true, error: undefined }); + }); +}); From eb6d23692f88cec12ece07ffc5a71b0bdb3b212c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:12:44 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- src/io/PipeReader.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/io/PipeReader.rs b/src/io/PipeReader.rs index 9d9e02142d7b..2f43db3b86f3 100644 --- a/src/io/PipeReader.rs +++ b/src/io/PipeReader.rs @@ -465,8 +465,7 @@ impl PosixBufferedReader { self.flags .remove(PosixFlags::POLLABLE | PosixFlags::NONBLOCKING); if matches!(self.handle, PollOrFd::Poll(_)) { - self.handle - .close_impl(None, None::, false); + self.handle.close_impl(None, None::, false); } self.handle = PollOrFd::Fd(fd); return sys::Result::Ok(()); From c45a338a51919df97f3ac93772b72e74d9d30a7f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:34:07 +0000 Subject: [PATCH 3/4] io(PipeReader): also fall back on EINVAL from kqueue macOS kqueue returns EINVAL (not EPERM) when registering a character device like /dev/null for EVFILT_READ, so the same zero-byte-close reproduces there. Extend the fallback to cover EINVAL on all POSIX in addition to EPERM on Linux, matching the existing IOWriter::__start handling. --- src/io/PipeReader.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/io/PipeReader.rs b/src/io/PipeReader.rs index 2f43db3b86f3..d5d9ca8c198d 100644 --- a/src/io/PipeReader.rs +++ b/src/io/PipeReader.rs @@ -456,12 +456,15 @@ impl PosixBufferedReader { self.handle = PollOrFd::Fd(fd); } if let sys::Result::Err(err) = self.try_register_poll() { - // On Linux, epoll_ctl(EPOLL_CTL_ADD) returns EPERM for fds whose - // file_operations lack .poll (e.g. /dev/null, /dev/zero). Such fds - // are always-readable, so fall back to the non-pollable path - // instead of tearing the reader down. Mirrors IOWriter::__start. - #[cfg(any(target_os = "linux", target_os = "android"))] - if err.get_errno() == sys::E::EPERM { + // epoll_ctl/kevent reject fds whose driver has no poll support + // (e.g. /dev/null, /dev/zero): EPERM from epoll on Linux, EINVAL + // from kqueue on macOS. Such fds are always-readable, so fall + // back to the non-pollable path instead of tearing the reader + // down. Mirrors IOWriter::__start. + let fd_not_pollable = matches!(err.get_errno(), sys::E::EINVAL) + || (cfg!(any(target_os = "linux", target_os = "android")) + && err.get_errno() == sys::E::EPERM); + if fd_not_pollable { self.flags .remove(PosixFlags::POLLABLE | PosixFlags::NONBLOCKING); if matches!(self.handle, PollOrFd::Poll(_)) { From 37bc91cf2463627254632d89c1a27e305ed9b4a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:39:45 +0000 Subject: [PATCH 4/4] ci: retrigger