Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 14 additions & 2 deletions src/runtime/server/FileRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,13 +435,22 @@ impl FileRoute {
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.
let stream_to_eof = file_type == FileType::File
&& 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 +546,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
62 changes: 62 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,67 @@ 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 });
},
});

// 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,
}).toEqual({ status: 200, hasName: true, nonEmpty: true });
expect(res.headers.get("content-length")).not.toBe("0");
}

// 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,
}).toEqual({ status: 200, hasName: true, nonEmpty: true });
expect(res.headers.get("content-length")).not.toBe("0");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// 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: "" });
}
},
);

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