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
8 changes: 8 additions & 0 deletions src/runtime/server/FileResponseStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,14 @@ impl FileResponseStream {
pub(crate) fn on_reader_done(&mut self) {
// Adopts the in-flight read ref taken before `reader.read()`.
let _guard = self.take_read_ref();
// BufferedReader skips on_read_chunk for empty chunks, so EOF-at-first-
// read (a genuinely empty file) lands here without RESPONSE_DONE set.
if !self.state.contains(State::RESPONSE_DONE) {
self.state.insert(State::RESPONSE_DONE);
self.detach_resp();
self.resp.end(b"", self.resp.should_close_connection());
(self.on_complete)(self.ctx, self.resp);
}
self.finish();
}

Expand Down
37 changes: 29 additions & 8 deletions src/runtime/server/FileRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,19 +398,25 @@ impl FileRoute {
.header(b"if-modified-since")
.and_then(crate::jsc_hooks::parse_http_date);

let (can_serve_file, size, file_type, pollable): (bool, u64, FileType, bool) = 'brk: {
let (can_serve_file, size, file_type, pollable, is_regular): (
bool,
u64,
FileType,
bool,
bool,
) = '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, FileType::File, false, 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 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, FileType::File, false, false);
}

// `Cell::take` → mutate → `set`: single-threaded event loop, no
Expand All @@ -420,28 +426,40 @@ 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, _size, FileType::Pipe, true, false);
}

if bun_sys::S::ISSOCK(mode) {
break 'brk (true, _size, FileType::Socket, true);
break 'brk (true, _size, FileType::Socket, true, false);
}

break 'brk (true, _size, FileType::File, false);
break 'brk (true, _size, FileType::File, false, bun_sys::S::ISREG(mode));
};

if !can_serve_file {
req.set_yield(true);
return;
}

// procfs/sysfs regular files report st_size == 0 but yield content on
// read(); for an unsliced Bun.file() route, read to EOF (chunked, no
// Content-Length) instead of trusting stat and serving an empty body.
// HEAD keeps the stat-derived framing: it never reads, so procfs is
// indistinguishable from a genuinely empty file.
let stream_to_eof = is_regular
&& method != Method::HEAD
&& size == 0
&& this.blob.offset.get() == 0
&& this.blob.size.get() == crate::webcore::blob::MAX_SIZE;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Range applies to the slice the route was configured with, not the
// underlying file: a Bun.file(p).slice(a,b) route exposes only [a,b).
// RFC 9110 §14.2: Range is only defined for GET (HEAD mirrors GET's
// headers). Skip if the route has a non-200 status or the user already
// set Content-Range — they're managing partial responses themselves.
let range: RangeRequest::Result = if (method == Method::GET || method == Method::HEAD)
&& file_type == FileType::File
&& !stream_to_eof
&& this.status_code == 200
&& !this.has_content_range_header
{
Expand Down Expand Up @@ -537,15 +555,18 @@ impl FileRoute {
} else {
0
},
if file_type == FileType::File && this.blob.size.get() > 0 {
if file_type == FileType::File && !stream_to_eof && this.blob.size.get() > 0 {
Some(size)
} else {
None
},
),
};

if file_type == FileType::File && !resp.state().has_written_content_length_header() {
if file_type == FileType::File
&& !stream_to_eof
&& !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();
}
Expand Down
26 changes: 15 additions & 11 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1822,11 +1822,18 @@ where
(bun_io::FileType::File, false)
};

let original_size = match &self.blob {
AnyBlob::Blob(b) => b.size.get(),
let (original_size, blob_offset) = match &self.blob {
AnyBlob::Blob(b) => (b.size.get(), b.offset.get()),
_ => unreachable!(),
};
let stat_size: BlobSizeType = BlobSizeType::try_from(stat.st_size.max(0)).unwrap();
// procfs/sysfs regular files report st_size == 0 but yield content on
// read(); for an unsliced Bun.file() body, read to EOF (chunked, no
// Content-Length) instead of trusting stat and serving an empty body.
let stream_to_eof = is_regular
&& stat_size == 0
&& blob_offset == 0
&& original_size == crate::webcore::blob::MAX_SIZE;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if let AnyBlob::Blob(b) = &mut self.blob {
b.size.set(if is_regular {
stat_size
Expand All @@ -1835,22 +1842,18 @@ where
});
}

self.flags.set_needs_content_length(true);
let blob_offset = match &self.blob {
AnyBlob::Blob(b) => b.offset.get(),
_ => unreachable!(),
};
self.flags.set_needs_content_length(!stream_to_eof);
self.sendfile = SendfileContext {
remain: blob_offset + original_size,
offset: blob_offset,
total: 0,
};
if is_regular && auto_close {
if is_regular && !stream_to_eof && auto_close {
self.flags.set_needs_content_range(
self.sendfile.remain.saturating_sub(self.sendfile.offset) != stat_size,
);
}
if is_regular {
if is_regular && !stream_to_eof {
self.sendfile.offset = self.sendfile.offset.min(stat_size);
self.sendfile.remain = self
.sendfile
Expand Down Expand Up @@ -1883,6 +1886,7 @@ where
// RFC 9110 §14.2: Range is only defined for GET (HEAD mirrors GET's headers).
let method_allows_range = self.method == Method::GET || self.method == Method::HEAD;
if is_regular
&& !stream_to_eof
&& method_allows_range
&& !user_handles_range
&& is_whole_file
Expand Down Expand Up @@ -1929,7 +1933,7 @@ where

resp.run_corked_with_type(Self::render_metadata_corked, self);

if (is_regular && self.sendfile.remain == 0) || !self.method.has_body() {
if (is_regular && !stream_to_eof && self.sendfile.remain == 0) || !self.method.has_body() {
if auto_close {
fd.close();
}
Expand Down Expand Up @@ -1970,7 +1974,7 @@ where
file_type,
pollable,
offset: self.sendfile.offset as u64,
length: if is_regular {
length: if is_regular && !stream_to_eof {
Some(self.sendfile.remain as u64)
} else {
None
Expand Down
66 changes: 66 additions & 0 deletions test/js/bun/http/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isIntelMacOS,
isIPv4,
isIPv6,
isLinux,
isPosix,
tempDir,
tls,
Expand Down Expand Up @@ -1996,6 +1997,71 @@ it("propagates content-type from a Bun.file()'s file path in fetch()", async ()
expect(res.headers.get("Content-Type")).toBe("text/plain;charset=utf-8");
});

// procfs/sysfs regular files report st_size == 0 but are readable; the
// sendfile path used to trust stat and serve a 200 Content-Length: 0 empty
// body while Bun.file().text() and the .stream() route returned the content.
it.skipIf(!isLinux)("serves the full content of a Bun.file() whose stat size is 0 (procfs)", async () => {
const P = "/proc/self/status";
const apiText = await Bun.file(P).text();
expect(apiText.length).toBeGreaterThan(0);
expect(apiText).toContain("Name:");

using dir = tempDir("serve-procfs", { "empty.bin": "" });
const emptyPath = join(String(dir), "empty.bin");

using server = Bun.serve({
port: 0,
development: false,
routes: {
"/route": new Response(Bun.file(P)),
},
fetch(req) {
const { pathname } = new URL(req.url);
if (pathname === "/file") return new Response(Bun.file(P));
if (pathname === "/empty") return new Response(Bun.file(emptyPath));
return new Response("not found", { status: 404 });
},
});

// no stat-derived framing: Content-Length is either absent (chunked) or
// the exact body length written by uWS end() when it fits in one read.
const okCL = (res: Response, body: string) => {
const cl = res.headers.get("content-length");
return cl === null || cl === String(body.length);
};

// fetch-handler path (RequestContext.do_sendfile)
{
const res = await fetch(new URL("/file", server.url));
const body = await res.text();
expect({
status: res.status,
hasName: body.includes("Name:"),
nonEmpty: body.length > 0,
okCL: okCL(res, body),
}).toEqual({ status: 200, hasName: true, nonEmpty: true, okCL: true });
}

// static-route path (FileRoute)
{
const res = await fetch(new URL("/route", server.url));
const body = await res.text();
expect({
status: res.status,
hasName: body.includes("Name:"),
nonEmpty: body.length > 0,
okCL: okCL(res, body),
}).toEqual({ status: 200, hasName: true, nonEmpty: true, okCL: true });
}

// a real 0-byte file still serves as empty (one read() hits EOF)
{
const res = await fetch(new URL("/empty", server.url));
const body = await res.text();
expect({ status: res.status, body }).toEqual({ status: 200, body: "" });
}
});
Comment thread
robobun marked this conversation as resolved.

it("does propagate type for Blob", async () => {
using server = Bun.serve({
port: 0,
Expand Down
Loading