Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/runtime/server/FileResponseStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
robobun marked this conversation as resolved.
resp.end(b"", resp.should_close_connection());
(self.on_complete.get())(self.ctx.get(), resp);
}

Expand Down
143 changes: 143 additions & 0 deletions test/js/bun/http/bun-serve-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();
const { promise: headSeen, resolve: resolveHeadSeen } = Promise.withResolvers<void>();
const { promise: payloadSeen, resolve: resolvePayloadSeen } = Promise.withResolvers<void>();
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
Expand Down