From a26141ec89c22a2a9a6decaa64f4c92840fe0b69 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:36:26 +0000 Subject: [PATCH 1/5] Bun.serve: chunk-frame FIFO/pipe file bodies instead of sending Content-Length: 0 do_sendfile wrote Content-Length from the stat-derived blob size for every file body and then streamed non-regular fds to EOF with no length bound. A FIFO stats as 0 bytes, so the head said Content-Length: 0 while the pipe bytes still went out after it. On a keep-alive connection those bytes land where the client parses the next response's status line (RFC 9112 6.3). Only set needs_content_length for regular files. With no Content-Length marked, uWS's write()/end() path chunk-frames the body (or writes a correct Content-Length when the whole body arrives in the single end() call), and the regular-file sendfile fast path is unchanged. --- src/runtime/server/RequestContext.rs | 7 +- test/js/bun/http/bun-serve-file.test.ts | 89 ++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 90b923e371a2..670a12818cd4 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1797,7 +1797,12 @@ where }); } - self.flags.set_needs_content_length(true); + // Non-regular files (FIFOs, character devices, sockets) have no + // meaningful stat size. Writing Content-Length from it and then + // streaming the fd to EOF puts body bytes on the wire past the + // declared length, which desyncs the next response on a keep-alive + // connection. Leave the header unset so uWS chunk-frames the body. + self.flags.set_needs_content_length(is_regular); let blob_offset = match &self.blob { AnyBlob::Blob(b) => b.offset.get(), _ => unreachable!(), diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 9a9f7d971208..90bb5ffdff00 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -2,7 +2,7 @@ import type { Server } from "bun"; 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 { closeSync, openSync, unlinkSync, writeSync } from "node:fs"; import { join } from "node:path"; const LARGE_SIZE = 1024 * 1024 * 8; @@ -1069,6 +1069,93 @@ process.exit(0); 30_000, ); +// A FIFO's stat size is 0, but the body length is unknown until EOF. Writing +// Content-Length from the stat size and then streaming the pipe to EOF puts +// body bytes on the wire past the declared length; on a keep-alive connection +// those bytes land where the client parses the next response's status line +// (RFC 9112 6.3). The response must be chunk-framed instead. +test.skipIf(isWindows)("Response(Bun.file(FIFO)) frames the body as chunked, not Content-Length: 0", async () => { + using dir = tempDir("serve-fifo-framing", { + "plain.txt": "SECOND-RESPONSE", + }); + const fifoPath = join(String(dir), "body.fifo"); + mkfifo(fifoPath); + + // Hold the FIFO open read+write for the whole test so the server's + // O_RDONLY|O_NONBLOCK open always finds a writer: its reads then EAGAIN + // instead of reporting EOF before we have written the payload. + const writerFd = openSync(fifoPath, "r+"); + + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + return new URL(req.url).pathname === "/fifo" + ? new Response(Bun.file(fifoPath)) + : new Response(Bun.file(join(String(dir), "plain.txt"))); + }, + }); + + const { promise: wireDone, resolve: resolveWire } = Promise.withResolvers(); + let wire = ""; + const client = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(s) { + s.write("GET /fifo HTTP/1.1\r\nHost: x\r\n\r\n"); + }, + data(_s, d) { + wire += Buffer.from(d).toString("latin1"); + if (wire.includes("SECOND-RESPONSE")) resolveWire(wire); + }, + close() { + resolveWire(wire); + }, + error() { + resolveWire(wire); + }, + }, + }); + + // The payload sits in the FIFO buffer (kept alive by writerFd) until the + // server opens its read end; then the server's first body write flushes it + // to the wire, which proves the server's fd is open and we can close ours + // to signal EOF. + writeSync(writerFd, "PIPEBYTES!"); + while (!wire.includes("PIPEBYTES!")) await Bun.sleep(0); + closeSync(writerFd); + + // Second request on the same keep-alive connection. With correct framing + // the two responses are independently delimited; the broken build wrote the + // pipe bytes raw after a Content-Length: 0 head, so they abut the next + // status line. + client.write("GET /plain HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + + const captured = await wireDone; + client.end(); + + const head1 = captured.split("\r\n\r\n")[0]; + expect({ + firstStatus: head1.split("\r\n")[0], + firstHasContentLength: /^content-length:/im.test(head1), + firstIsChunked: /^transfer-encoding:\s*chunked/im.test(head1), + bodyDelivered: captured.includes("PIPEBYTES!"), + // The broken build put the pipe bytes immediately before the next status + // line with at most a stray CRLF between them; with chunked framing the + // terminator (0\r\n\r\n) separates them. + gluedToNextStatusLine: /PIPEBYTES!(?:\r\n)?HTTP\/1\.1/.test(captured), + secondBody: captured.includes("SECOND-RESPONSE"), + }).toEqual({ + firstStatus: "HTTP/1.1 200 OK", + firstHasContentLength: false, + firstIsChunked: true, + bodyDelivered: true, + gluedToNextStatusLine: false, + secondBody: true, + }); +}); + // A request that declares a body arms the request-body (onData) callback on // the uWS response before the fetch handler runs. uWS keeps a single shared // userdata slot per response, so when the handler returns a file response From 83bfdcf301b35d2fed3fb7aae1e243ed480403c5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:49:53 +0000 Subject: [PATCH 2/5] test: release the FIFO writer fd via try/finally Also exit the body-on-wire poll if the socket closes first, so a regression that never delivers the body fails the assertion instead of spinning to timeout. --- test/js/bun/http/bun-serve-file.test.ts | 142 +++++++++++++----------- 1 file changed, 75 insertions(+), 67 deletions(-) diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 90bb5ffdff00..464faf1af5dd 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1084,76 +1084,84 @@ test.skipIf(isWindows)("Response(Bun.file(FIFO)) frames the body as chunked, not // Hold the FIFO open read+write for the whole test so the server's // O_RDONLY|O_NONBLOCK open always finds a writer: its reads then EAGAIN // instead of reporting EOF before we have written the payload. - const writerFd = openSync(fifoPath, "r+"); - - await using server = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - return new URL(req.url).pathname === "/fifo" - ? new Response(Bun.file(fifoPath)) - : new Response(Bun.file(join(String(dir), "plain.txt"))); - }, - }); - - const { promise: wireDone, resolve: resolveWire } = Promise.withResolvers(); - let wire = ""; - const client = await Bun.connect({ - hostname: "127.0.0.1", - port: server.port, - socket: { - open(s) { - s.write("GET /fifo HTTP/1.1\r\nHost: x\r\n\r\n"); - }, - data(_s, d) { - wire += Buffer.from(d).toString("latin1"); - if (wire.includes("SECOND-RESPONSE")) resolveWire(wire); - }, - close() { - resolveWire(wire); + let writerFd: number | undefined = openSync(fifoPath, "r+"); + try { + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + return new URL(req.url).pathname === "/fifo" + ? new Response(Bun.file(fifoPath)) + : new Response(Bun.file(join(String(dir), "plain.txt"))); }, - error() { - resolveWire(wire); + }); + + const { promise: wireDone, resolve: resolveWire } = Promise.withResolvers(); + let wire = ""; + let socketClosed = false; + const client = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(s) { + s.write("GET /fifo HTTP/1.1\r\nHost: x\r\n\r\n"); + }, + data(_s, d) { + wire += Buffer.from(d).toString("latin1"); + if (wire.includes("SECOND-RESPONSE")) resolveWire(wire); + }, + close() { + socketClosed = true; + resolveWire(wire); + }, + error() { + socketClosed = true; + resolveWire(wire); + }, }, - }, - }); + }); - // The payload sits in the FIFO buffer (kept alive by writerFd) until the - // server opens its read end; then the server's first body write flushes it - // to the wire, which proves the server's fd is open and we can close ours - // to signal EOF. - writeSync(writerFd, "PIPEBYTES!"); - while (!wire.includes("PIPEBYTES!")) await Bun.sleep(0); - closeSync(writerFd); - - // Second request on the same keep-alive connection. With correct framing - // the two responses are independently delimited; the broken build wrote the - // pipe bytes raw after a Content-Length: 0 head, so they abut the next - // status line. - client.write("GET /plain HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); - - const captured = await wireDone; - client.end(); - - const head1 = captured.split("\r\n\r\n")[0]; - expect({ - firstStatus: head1.split("\r\n")[0], - firstHasContentLength: /^content-length:/im.test(head1), - firstIsChunked: /^transfer-encoding:\s*chunked/im.test(head1), - bodyDelivered: captured.includes("PIPEBYTES!"), - // The broken build put the pipe bytes immediately before the next status - // line with at most a stray CRLF between them; with chunked framing the - // terminator (0\r\n\r\n) separates them. - gluedToNextStatusLine: /PIPEBYTES!(?:\r\n)?HTTP\/1\.1/.test(captured), - secondBody: captured.includes("SECOND-RESPONSE"), - }).toEqual({ - firstStatus: "HTTP/1.1 200 OK", - firstHasContentLength: false, - firstIsChunked: true, - bodyDelivered: true, - gluedToNextStatusLine: false, - secondBody: true, - }); + // The payload sits in the FIFO buffer (kept alive by writerFd) until the + // server opens its read end; then the server's first body write flushes it + // to the wire, which proves the server's fd is open and we can close ours + // to signal EOF. If the socket closes first the body never arrives, so + // stop waiting and let the assertion below report it. + writeSync(writerFd, "PIPEBYTES!"); + while (!wire.includes("PIPEBYTES!") && !socketClosed) await Bun.sleep(0); + closeSync(writerFd); + writerFd = undefined; + + // Second request on the same keep-alive connection. With correct framing + // the two responses are independently delimited; the broken build wrote the + // pipe bytes raw after a Content-Length: 0 head, so they abut the next + // status line. + client.write("GET /plain HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + + const captured = await wireDone; + client.end(); + + const head1 = captured.split("\r\n\r\n")[0]; + expect({ + firstStatus: head1.split("\r\n")[0], + firstHasContentLength: /^content-length:/im.test(head1), + firstIsChunked: /^transfer-encoding:\s*chunked/im.test(head1), + bodyDelivered: captured.includes("PIPEBYTES!"), + // The broken build put the pipe bytes immediately before the next status + // line with at most a stray CRLF between them; with chunked framing the + // terminator (0\r\n\r\n) separates them. + gluedToNextStatusLine: /PIPEBYTES!(?:\r\n)?HTTP\/1\.1/.test(captured), + secondBody: captured.includes("SECOND-RESPONSE"), + }).toEqual({ + firstStatus: "HTTP/1.1 200 OK", + firstHasContentLength: false, + firstIsChunked: true, + bodyDelivered: true, + gluedToNextStatusLine: false, + secondBody: true, + }); + } finally { + if (writerFd !== undefined) closeSync(writerFd); + } }); // A request that declares a body arms the request-body (onData) callback on From 1cd24ab2bfe98d5bfb58257bd4294316c55e75fa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:51:18 +0000 Subject: [PATCH 3/5] shorten the non-regular-file Content-Length comment --- src/runtime/server/RequestContext.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 670a12818cd4..53fa7702a9eb 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1797,11 +1797,8 @@ where }); } - // Non-regular files (FIFOs, character devices, sockets) have no - // meaningful stat size. Writing Content-Length from it and then - // streaming the fd to EOF puts body bytes on the wire past the - // declared length, which desyncs the next response on a keep-alive - // connection. Leave the header unset so uWS chunk-frames the body. + // Non-regular files (FIFO/chardev/socket) have no meaningful stat + // size; leave Content-Length unset so uWS chunk-frames the body. self.flags.set_needs_content_length(is_regular); let blob_offset = match &self.blob { AnyBlob::Blob(b) => b.offset.get(), From 7d63fabd0c7f36567f172acface93f6c4f1d05cd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:52:03 +0000 Subject: [PATCH 4/5] drop inline comment; is_regular is self-explanatory, rationale lives in the commit message --- src/runtime/server/RequestContext.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 53fa7702a9eb..c9b085f91d5a 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1797,8 +1797,6 @@ where }); } - // Non-regular files (FIFO/chardev/socket) have no meaningful stat - // size; leave Content-Length unset so uWS chunk-frames the body. self.flags.set_needs_content_length(is_regular); let blob_offset = match &self.blob { AnyBlob::Blob(b) => b.offset.get(), From 6eba603bcb02dedacec97fb49fa432c7ca2f9ad9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:17:21 +0000 Subject: [PATCH 5/5] test: assert only on response framing, not keep-alive-after-FIFO-EOF The second-request assertion depended on the server cleanly terminating the chunked body when the FIFO writer closes, which turns out to be platform-dependent (Linux serves the pipelined request without a 0\r\n\r\n terminator; macOS force-closes). That is a separate pre-existing behaviour and not what this test covers, so drop the keep-alive leg and assert on what this change actually fixes: no Content-Length in the head, body framed as chunked, and no body bytes emitted past a Content-Length: 0. --- test/js/bun/http/bun-serve-file.test.ts | 70 +++++++++---------------- 1 file changed, 26 insertions(+), 44 deletions(-) diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 464faf1af5dd..76e825aeb2f2 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1075,30 +1075,27 @@ process.exit(0); // those bytes land where the client parses the next response's status line // (RFC 9112 6.3). The response must be chunk-framed instead. test.skipIf(isWindows)("Response(Bun.file(FIFO)) frames the body as chunked, not Content-Length: 0", async () => { - using dir = tempDir("serve-fifo-framing", { - "plain.txt": "SECOND-RESPONSE", - }); + using dir = tempDir("serve-fifo-framing", {}); const fifoPath = join(String(dir), "body.fifo"); mkfifo(fifoPath); - // Hold the FIFO open read+write for the whole test so the server's - // O_RDONLY|O_NONBLOCK open always finds a writer: its reads then EAGAIN - // instead of reporting EOF before we have written the payload. - let writerFd: number | undefined = openSync(fifoPath, "r+"); + // Hold the FIFO open read+write so the server's O_RDONLY|O_NONBLOCK open + // always finds a writer (its reads EAGAIN instead of reporting EOF before we + // write). The fd is released in `finally`; we do not close it mid-test to + // signal EOF because the server's FIFO-EOF handling is platform-dependent + // and not what this test is about. + const writerFd = openSync(fifoPath, "r+"); try { await using server = Bun.serve({ port: 0, hostname: "127.0.0.1", - fetch(req) { - return new URL(req.url).pathname === "/fifo" - ? new Response(Bun.file(fifoPath)) - : new Response(Bun.file(join(String(dir), "plain.txt"))); + fetch() { + return new Response(Bun.file(fifoPath)); }, }); const { promise: wireDone, resolve: resolveWire } = Promise.withResolvers(); let wire = ""; - let socketClosed = false; const client = await Bun.connect({ hostname: "127.0.0.1", port: server.port, @@ -1108,59 +1105,44 @@ test.skipIf(isWindows)("Response(Bun.file(FIFO)) frames the body as chunked, not }, data(_s, d) { wire += Buffer.from(d).toString("latin1"); - if (wire.includes("SECOND-RESPONSE")) resolveWire(wire); + if (wire.includes("PIPEBYTES!")) resolveWire(wire); }, close() { - socketClosed = true; resolveWire(wire); }, error() { - socketClosed = true; resolveWire(wire); }, }, }); // The payload sits in the FIFO buffer (kept alive by writerFd) until the - // server opens its read end; then the server's first body write flushes it - // to the wire, which proves the server's fd is open and we can close ours - // to signal EOF. If the socket closes first the body never arrives, so - // stop waiting and let the assertion below report it. + // server opens its read end; the server's first body write then carries it + // to the wire together with whatever framing the head declared. writeSync(writerFd, "PIPEBYTES!"); - while (!wire.includes("PIPEBYTES!") && !socketClosed) await Bun.sleep(0); - closeSync(writerFd); - writerFd = undefined; - - // Second request on the same keep-alive connection. With correct framing - // the two responses are independently delimited; the broken build wrote the - // pipe bytes raw after a Content-Length: 0 head, so they abut the next - // status line. - client.write("GET /plain HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); - const captured = await wireDone; client.end(); - const head1 = captured.split("\r\n\r\n")[0]; + const head = captured.split("\r\n\r\n")[0]; + // The broken build wrote `content-length: 0` from the FIFO's stat size and + // then emitted the pipe bytes raw after the head (body past the declared + // length). With the fix the head carries no Content-Length and the first + // body write enters chunked mode. expect({ - firstStatus: head1.split("\r\n")[0], - firstHasContentLength: /^content-length:/im.test(head1), - firstIsChunked: /^transfer-encoding:\s*chunked/im.test(head1), + status: head.split("\r\n")[0], + hasContentLength: /^content-length:/im.test(head), + isChunked: /^transfer-encoding:\s*chunked/im.test(head), bodyDelivered: captured.includes("PIPEBYTES!"), - // The broken build put the pipe bytes immediately before the next status - // line with at most a stray CRLF between them; with chunked framing the - // terminator (0\r\n\r\n) separates them. - gluedToNextStatusLine: /PIPEBYTES!(?:\r\n)?HTTP\/1\.1/.test(captured), - secondBody: captured.includes("SECOND-RESPONSE"), + bodyBytesPastContentLengthZero: /^content-length:\s*0$/im.test(head) && captured.includes("PIPEBYTES!"), }).toEqual({ - firstStatus: "HTTP/1.1 200 OK", - firstHasContentLength: false, - firstIsChunked: true, + status: "HTTP/1.1 200 OK", + hasContentLength: false, + isChunked: true, bodyDelivered: true, - gluedToNextStatusLine: false, - secondBody: true, + bodyBytesPastContentLengthZero: false, }); } finally { - if (writerFd !== undefined) closeSync(writerFd); + closeSync(writerFd); } });