diff --git a/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index e9f9e24755cc..8b9551869cf7 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -373,19 +373,20 @@ impl FileRoute { } }); - let (can_serve_file, size, file_type, pollable): (bool, u64, FileType, bool) = 'brk: { + let (can_serve_file, offset, size, file_type, pollable) = 'brk: { let stat = match bun_sys::fstat(fd) { Ok(s) => s, // file_type is never read because can_serve_file == false - Err(_) => break 'brk (false, 0, FileType::File, false), + Err(_) => break 'brk (false, 0, 0, FileType::File, false), }; let stat_size: u64 = u64::try_from(stat.st_size.max(0)).expect("int cast"); - let _size: u64 = stat_size.min(this.blob.size.get()); + let offset: u64 = this.blob.offset.get().min(stat_size); + let size: u64 = this.blob.size.get().min(stat_size - offset); let mode = stat.st_mode as bun_sys::Mode; if bun_sys::S::ISDIR(mode) { - break 'brk (false, 0, FileType::File, false); + break 'brk (false, 0, 0, FileType::File, false); } // `Cell::take` → mutate → `set`: single-threaded event loop, no @@ -395,14 +396,14 @@ impl FileRoute { this.stat_hash.set(sh); if bun_sys::S::ISFIFO(mode) || bun_sys::S::ISCHR(mode) { - break 'brk (true, _size, FileType::Pipe, true); + break 'brk (true, offset, size, FileType::Pipe, true); } if bun_sys::S::ISSOCK(mode) { - break 'brk (true, _size, FileType::Socket, true); + break 'brk (true, offset, size, FileType::Socket, true); } - break 'brk (true, _size, FileType::File, false); + break 'brk (true, offset, size, FileType::File, false); }; if !can_serve_file { @@ -466,33 +467,31 @@ impl FileRoute { return; } + // `None` (read to EOF) is only for pipes and sockets; a file's body is its Content-Length. let (body_offset, body_len): (u64, Option) = match range { RangeRequest::Result::Satisfiable { .. } => { let (start, len) = write_content_range(resp, range, size).unwrap(); - (this.blob.offset.get() + start, Some(len)) + (offset + start, Some(len)) } RangeRequest::Result::Unsatisfiable => { write_content_range(resp, range, size); resp.end(b"", resp.should_close_connection()); return; } - RangeRequest::Result::None => ( + RangeRequest::Result::None => { if file_type == FileType::File { - this.blob.offset.get() + (offset, Some(size)) } else { - 0 - }, - if file_type == FileType::File && this.blob.size.get() > 0 { - Some(size) - } else { - None - }, - ), + (0, None) + } + } }; - if file_type == FileType::File && !resp.state().has_written_content_length_header() { - resp.write_header_int(b"content-length", body_len.unwrap_or(size)); - resp.mark_wrote_content_length_header(); + if let Some(len) = body_len { + if !resp.state().has_written_content_length_header() { + resp.write_header_int(b"content-length", len); + resp.mark_wrote_content_length_header(); + } } if method == Method::HEAD { @@ -500,6 +499,11 @@ impl FileRoute { return; } + if body_len == Some(0) { + resp.end(b"", resp.should_close_connection()); + return; + } + // Hand ownership of the fd to FileResponseStream; disable the defer close. // The route ref taken at the top of on() is released in on_stream_complete. *fd_guard = false; diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index 848040a06468..45472efc8e63 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -226,6 +226,13 @@ impl ReadableStream { let blobby = self.ptr.file().expect("matched File"); if let webcore::file_reader::Lazy::Blob(store) = blobby.lazy.get() { let blob = Blob::init_with_store(store.clone(), global_this); + // The window `from_blob_copy_ref` moved onto the reader. + if let Some(offset) = blobby.start_offset { + blob.offset.set(offset as webcore::blob::SizeType); + } + if let Some(size) = blobby.max_size { + blob.size.set(size as webcore::blob::SizeType); + } // it should be lazy, file shouldn't have opened yet. debug_assert!(!blobby.started.get()); self.done(global_this); diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index dada59606156..923d0a84e65e 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -72,6 +72,14 @@ describe("Bun.file in serve routes", () => { }), "/partial.txt": new Response(Bun.file(join(tempDir, "partial.txt"))), "/partial-slice.txt": new Response(Bun.file(join(tempDir, "partial.txt")).slice(5, 10)), + // Rendering a handler response built from an unread Bun.file() stream + // turns the stream back into the file Blob; the slice must survive that. + "/partial-slice-stream-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).slice(5, 10).stream()), + "/partial-open-slice-stream-handler": () => + new Response(Bun.file(join(tempDir, "partial.txt")).slice(10).stream()), + "/partial-empty-slice-stream-handler": () => + new Response(Bun.file(join(tempDir, "partial.txt")).slice(5, 5).stream()), + "/partial-stream-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).stream()), "/fd-not-supported.txt": (() => { // This would test file descriptors, but they're not supported yet return new Response(Bun.file(join(tempDir, "hello.txt"))); @@ -713,6 +721,29 @@ describe("Bun.file in serve routes", () => { expect(res.headers.get("Content-Length")).toBe("5"); }); + it("serves the slice behind a sliced file's stream from a handler", async () => { + const serve = async (pathname: string) => { + const get = await fetch(new URL(pathname, server.url)); + const head = await fetch(new URL(pathname, server.url), { method: "HEAD" }); + return { + body: await get.text(), + contentLength: get.headers.get("Content-Length"), + headContentLength: head.headers.get("Content-Length"), + }; + }; + expect({ + "slice(5, 10)": await serve("/partial-slice-stream-handler"), + "slice(10)": await serve("/partial-open-slice-stream-handler"), + "slice(5, 5)": await serve("/partial-empty-slice-stream-handler"), + "whole file": await serve("/partial-stream-handler"), + }).toEqual({ + "slice(5, 10)": { body: "56789", contentLength: "5", headContentLength: "5" }, + "slice(10)": { body: "ABCDEF", contentLength: "6", headContentLength: "6" }, + "slice(5, 5)": { body: "", contentLength: "0", headContentLength: "0" }, + "whole file": { body: "0123456789ABCDEF", contentLength: "16", headContentLength: "16" }, + }); + }); + // The slice is shorter than the file, so the byte budget runs out before // the reader reports EOF: the response completes inline while a deferred // completion still hops through the event loop. Repeated requests must @@ -1162,6 +1193,85 @@ test.skipIf(isWindows)("Response(Bun.file(FIFO)) frames the body as chunked, not } }); +// A file route serves the window of the Bun.file() slice it was built from, +// given either the slice or its unread stream (which is turned back into the +// slice). FileRoute used to clamp the window to the file size without taking +// the offset off, and to send an empty window to EOF: on this 16-byte file +// slice(10) declared Content-Length: 16 and sent 6 bytes, slice(5, 5) declared +// 0 and sent 11. Only the wire shows that (RFC 9112 6.3); fetch() drops bytes +// past the declared length and turns a short body into a connection error. +test("file routes frame a slice that reaches or starts past EOF by the bytes they serve", async () => { + using dir = tempDir("serve-file-route-slice-framing", { "partial.txt": "0123456789ABCDEF" }); + const file = () => Bun.file(join(String(dir), "partial.txt")); + const windows = { + "slice(5, 10)": () => file().slice(5, 10), + "slice(10)": () => file().slice(10), + "slice(10, 100)": () => file().slice(10, 100), + "slice(5, 5)": () => file().slice(5, 5), + "slice(100)": () => file().slice(100), + "whole file": () => file(), + }; + const names = Object.keys(windows) as (keyof typeof windows)[]; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: Object.fromEntries( + names.flatMap((name, i) => [ + [`/blob/${i}`, new Response(windows[name]())], + [`/stream/${i}`, new Response(windows[name]().stream())], + ]), + ), + fetch: () => new Response("fallback", { status: 404 }), + }); + + // One GET on its own connection; `Connection: close` makes the server hang + // up once it considers the response finished, so `body` is every byte that + // followed the head, however many the head declared. + async function wire(path: string) { + const { promise, resolve } = Promise.withResolvers(); + let captured = ""; + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(socket) { + socket.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); + }, + data(_socket, chunk) { + captured += Buffer.from(chunk).toString("latin1"); + }, + close() { + resolve(captured); + }, + error() { + resolve(captured); + }, + }, + }); + const raw = await promise; + const headEnd = raw.indexOf("\r\n\r\n"); + return { + contentLength: /^content-length:\s*(\d+)/im.exec(raw.slice(0, headEnd))?.[1] ?? null, + body: raw.slice(headEnd + 4), + }; + } + + const results: Record = {}; + for (const [i, name] of names.entries()) { + results[name] = { blob: await wire(`/blob/${i}`), stream: await wire(`/stream/${i}`) }; + } + + const exactly = (body: string) => ({ contentLength: String(body.length), body }); + expect(results).toEqual({ + "slice(5, 10)": { blob: exactly("56789"), stream: exactly("56789") }, + "slice(10)": { blob: exactly("ABCDEF"), stream: exactly("ABCDEF") }, + "slice(10, 100)": { blob: exactly("ABCDEF"), stream: exactly("ABCDEF") }, + "slice(5, 5)": { blob: exactly(""), stream: exactly("") }, + "slice(100)": { blob: exactly(""), stream: exactly("") }, + "whole file": { blob: exactly("0123456789ABCDEF"), stream: exactly("0123456789ABCDEF") }, + }); +}); + // 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 diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts index 67e3375046e9..9d910c7b6e38 100644 --- a/test/js/web/fetch/body.test.ts +++ b/test/js/web/fetch/body.test.ts @@ -1,6 +1,6 @@ import { file, spawn, version, type Socket } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, exampleSite } from "harness"; +import { bunEnv, bunExe, exampleSite, tempDir } from "harness"; import net from "net"; const exampleServer = exampleSite("http"); @@ -287,6 +287,59 @@ for (const { body, fn } of bodyTypes) { expect(await subject.text()).toBe("bye"); expect(subject.bodyUsed).toBe(true); }); + + // The readers move an unread Bun.file() stream back into a Blob the same + // way. That Blob has to cover the slice the stream was made from, not + // the whole file. (text() is not in here: it pumps the stream instead.) + describe("made from a sliced Bun.file()", () => { + const alphabet = "abcdefghijklmnopqrstuvwxyz"; + + test("bytes() returns the window of the slice the stream was made from", async () => { + using dir = tempDir("body-file-slice-stream", { "data.txt": alphabet }); + const file = () => Bun.file(`${dir}/data.txt`); + const bytesOf = async (blob: Blob) => Buffer.from(await fn(blob.stream()).bytes()).toString(); + expect({ + "slice(3, 8)": await bytesOf(file().slice(3, 8)), + "slice(21)": await bytesOf(file().slice(21)), + "slice(3, 1000)": await bytesOf(file().slice(3, 1000)), + "slice(4, 4)": await bytesOf(file().slice(4, 4)), + "slice(3, 20).slice(2, 6)": await bytesOf(file().slice(3, 20).slice(2, 6)), + "whole file": await bytesOf(file()), + }).toEqual({ + "slice(3, 8)": "defgh", + "slice(21)": "vwxyz", + "slice(3, 1000)": "defghijklmnopqrstuvwxyz", + "slice(4, 4)": "", + "slice(3, 20).slice(2, 6)": "fghi", + "whole file": alphabet, + }); + }); + + test("arrayBuffer() and blob() return the slice too", async () => { + using dir = tempDir("body-file-slice-stream-readers", { "data.txt": alphabet }); + const slice = () => Bun.file(`${dir}/data.txt`).slice(3, 8); + const blob = await fn(slice().stream()).blob(); + expect({ + arrayBuffer: Buffer.from(await fn(slice().stream()).arrayBuffer()).toString(), + blob: [blob.size, await blob.text()], + }).toEqual({ + arrayBuffer: "defgh", + blob: [5, "defgh"], + }); + }); + + test("json() parses only the slice", async () => { + using dir = tempDir("body-file-slice-stream-json", { "data.json": `--{"ok":true}--` }); + expect(await fn(Bun.file(`${dir}/data.json`).slice(2, 13).stream()).json()).toEqual({ ok: true }); + }); + + test("bytes() after the body getter turned a sliced Bun.file() body into a stream", async () => { + using dir = tempDir("body-file-slice-body-getter", { "data.txt": alphabet }); + const subject = fn(Bun.file(`${dir}/data.txt`).slice(3, 8)); + expect(subject.body).toBeInstanceOf(ReadableStream); + expect([Buffer.from(await subject.bytes()).toString(), subject.bodyUsed]).toEqual(["defgh", true]); + }); + }); }); for (const { string, buffer } of utf8) { describe("arrayBuffer()", () => {