Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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