diff --git a/src/runtime/server/FileResponseStream.rs b/src/runtime/server/FileResponseStream.rs index a5ff5adc3737..bb74dc684398 100644 --- a/src/runtime/server/FileResponseStream.rs +++ b/src/runtime/server/FileResponseStream.rs @@ -541,7 +541,9 @@ impl FileResponseStream { self.insert_state(State::RESPONSE_DONE); self.detach_resp(); let resp = self.resp.get(); - resp.end_without_body(resp.should_close_connection()); + // Reader EOF with no trailing data: empty-body `end` completes the + // framing (0-chunk when chunked, `Content-Length: 0` otherwise). + resp.end(b"", resp.should_close_connection()); (self.on_complete.get())(self.ctx.get(), resp); } diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index dada59606156..10a6f8b6ecb1 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, rss, tempDir, tempDirWithFiles } from "harness"; import { mkfifo } from "mkfifo"; import { closeSync, openSync, unlinkSync, writeSync } from "node:fs"; +import { open as fsOpen } from "node:fs/promises"; import { join } from "node:path"; const LARGE_SIZE = 1024 * 1024 * 8; @@ -1162,6 +1163,148 @@ test.skipIf(isWindows)("Response(Bun.file(FIFO)) frames the body as chunked, not } }); +// When a FIFO's writer closes right after its last write (write()+close, the +// common producer pattern), the EOF reaches the pipe reader as a separate +// poll event with no pending bytes, and the stream completion path (not the +// data path) has to end the response. The broken build ended it with a bare +// CRLF and no terminating 0-chunk, so a chunk-framed body never became +// parseable (curl: "chunk hex-length char not a hex digit"), and a pipe that +// closed without writing produced a head with neither Content-Length nor +// Transfer-Encoding that never completes. Either chunked-with-terminator or +// Content-Length framing is acceptable; the response just has to be complete. +describe.skipIf(isWindows)("Response(Bun.file(FIFO)) ends the response when the pipe writer closes", () => { + // Returns the decoded body, or null if the wire bytes do not form a + // complete HTTP/1.1 message (the failure mode under test). + function decodeBody(wire: string): { status: string; body: string } | null { + const headEnd = wire.indexOf("\r\n\r\n"); + if (headEnd === -1) return null; + const head = wire.slice(0, headEnd); + const status = head.split("\r\n")[0]; + const body = wire.slice(headEnd + 4); + const contentLength = head.match(/^content-length:\s*(\d+)/im); + if (contentLength !== null) { + return body.length === Number(contentLength[1]) ? { status, body } : null; + } + if (!/^transfer-encoding:\s*chunked/im.test(head)) return null; + let out = ""; + let i = 0; + while (true) { + const sizeEnd = body.indexOf("\r\n", i); + if (sizeEnd === -1) return null; + const sizeText = body.slice(i, sizeEnd); + if (!/^[0-9a-f]+$/i.test(sizeText)) return null; + const size = parseInt(sizeText, 16); + i = sizeEnd + 2; + if (size === 0) return { status, body: out }; + if (i + size + 2 > body.length) return null; + out += body.slice(i, i + size); + i += size + 2; + } + } + + for (const [name, payload] of [ + ["write then close", "hello"], + ["close with no writes", ""], + ] as const) { + test.concurrent(name, async () => { + using dir = tempDir("serve-fifo-eof", {}); + const fifoPath = join(String(dir), "body.fifo"); + mkfifo(fifoPath); + + // Hold the FIFO open read+write until the real writer is established: + // the server's O_RDONLY|O_NONBLOCK open then always finds a writer (no + // instant EOF before the writer exists, and macOS kqueue registration + // needs a writer present to deliver events), and the write-end open + // below cannot block forever on a reader that already came and went. + const keeperFd = openSync(fifoPath, "r+"); + let keeperOpen = true; + + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + // Bounds how long a broken build keeps the unterminated response + // open; its force-close resolves the promises below with whatever + // reached the wire. + idleTimeout: 5, + fetch() { + return new Response(Bun.file(fifoPath)); + }, + }); + + const { promise: wireDone, resolve: resolveWire } = Promise.withResolvers(); + const { promise: headSeen, resolve: resolveHeadSeen } = Promise.withResolvers(); + const { promise: payloadSeen, resolve: resolvePayloadSeen } = Promise.withResolvers(); + let wire = ""; + await using client = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(s) { + s.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + }, + data(_s, d) { + wire += Buffer.from(d).toString("latin1"); + resolveHeadSeen(); + if (payload.length > 0 && wire.includes(payload)) resolvePayloadSeen(); + if (decodeBody(wire) !== null) resolveWire(wire); + }, + close() { + resolveHeadSeen(); + resolvePayloadSeen(); + resolveWire(wire); + }, + error() { + resolveHeadSeen(); + resolvePayloadSeen(); + resolveWire(wire); + }, + }, + }); + + try { + // The response head reaching the client means the fetch handler ran + // to completion: the server opened the FIFO's read end (finding the + // keeper's write end) and armed its poll. Only then hand over from + // the keeper to the real writer. + await headSeen; + const writer = await fsOpen(fifoPath, "w"); + try { + // Drop the keeper so `writer` holds the only write end; its close + // below is what delivers EOF. Writing first and closing only after + // the payload came out the other side pins down the shape under + // test: the pipe EOF reaches the server strictly after the data + // did, as its own poll event with no bytes left to read. + closeSync(keeperFd); + keeperOpen = false; + if (payload.length > 0) { + await writer.write(payload); + await payloadSeen; + } + } finally { + await writer.close(); + } + } finally { + if (keeperOpen) closeSync(keeperFd); + } + + const captured = await wireDone; + + const decoded = decodeBody(captured); + expect({ + complete: decoded !== null, + status: decoded?.status, + bodyLength: decoded?.body.length, + bodyMatches: decoded?.body === payload, + }).toEqual({ + complete: true, + status: "HTTP/1.1 200 OK", + bodyLength: payload.length, + bodyMatches: 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