Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b5828d0
Take the native blob path for Response-wrapped Bun.file() streams
alii Jun 1, 2026
ac2acbf
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 1, 2026
2e7d46c
test: add cancel/abort coverage for Response-wrapped file streams, us…
robobun Jun 2, 2026
5bc5995
Detach consumed file streams and fix HTMLRewriter panic on file-backe…
robobun Jun 2, 2026
b89b420
Document why formData() keeps the streaming path, pin its file-stream…
robobun Jun 2, 2026
95fae83
Drop dead to_blob_if_possible calls before try_blob_from_resolved_stream
robobun Jun 2, 2026
9844fd7
Report Content-Length for HEAD on file-stream responses
robobun Jun 2, 2026
65a3c01
Don't let resolve_size widen a sliced file Blob past its window
robobun Jun 2, 2026
97cb5d7
ci: retrigger
robobun Jun 2, 2026
8a7ef53
Merge branch 'main' into ali/response-file-stream-sendfile
alii Jun 2, 2026
aad89d3
Merge branch 'main' into ali/response-file-stream-sendfile
alii Jun 2, 2026
b6b4ae9
Merge branch 'main' into ali/response-file-stream-sendfile
alii Jun 2, 2026
03f5418
Refuse native blob conversion of reader-locked body streams
robobun Jun 3, 2026
ee0309c
Mark every natively converted stream disturbed, not just file streams
alii Jun 5, 2026
92e183c
Update blob-consumed stream test for the detached end state
robobun Jun 5, 2026
ab378e1
Document why the fetch-body buffering wait has no awaitable condition
robobun Jun 5, 2026
e1bb6f5
Clarify that has_reader implements only the reader half of the locked…
robobun Jun 5, 2026
8da2c60
Wire the stream-to-blob conversion into formData and spawn stdio
robobun Jun 10, 2026
36f4f70
Merge remote-tracking branch 'origin/main' into ali/response-file-str…
robobun Jun 10, 2026
6f07d3b
Drop has_reader now that isLocked matches the locked builtin
robobun Jun 10, 2026
675d347
Reclaim the Temporary read buffer in to_form_data_with_bytes
robobun Jun 10, 2026
26fdee3
Merge remote-tracking branch 'origin/main' into ali/response-file-str…
robobun Jun 10, 2026
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
24 changes: 22 additions & 2 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2910,9 +2910,29 @@ where
}
// toBlobIfPossible will typically convert .Blob streams, or .File streams into a Blob object, but cannot always.
readable_stream::Source::Blob(_)
| readable_stream::Source::File(_)
| readable_stream::Source::File(_) => {
// `value.to_blob_if_possible()` above can no longer
// see the stream once check_body_stream_ref has
// migrated it into the JS-side cached slot, so
// unread blob/file-backed Response streams land
// here. Convert now so file streams take the
// sendfile/native blob path instead of the
// per-chunk JS streaming loop.
let mut stream = stream;
if let Some(blob) = stream.to_any_blob(global_this) {
this.response_body_readable_stream_ref.deinit();
this.blob = blob;
this.render_with_blob_from_body_value();
return;
Comment thread
robobun marked this conversation as resolved.
}
if let Some(resp) = this.resp {
let mut pair = StreamPair { stream, this };
resp.run_corked_with_type(Self::do_render_stream, &raw mut pair);
}
return;
}
// These are the common scenario:
| readable_stream::Source::JavaScript
readable_stream::Source::JavaScript
| readable_stream::Source::Direct => {
if let Some(resp) = this.resp {
let mut pair = StreamPair { stream, this };
Expand Down
94 changes: 74 additions & 20 deletions src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1778,6 +1778,51 @@
}
}

/// Try to convert a still-unread blob/file-backed body stream back into a
/// Blob body value so consumers take the native blob paths (sendfile,
/// buffered reads) instead of the per-chunk JS streaming loop.
///
/// `Value::to_blob_if_possible` can only consult the native
/// `Locked.readable` slot, but `check_body_stream_ref` migrates the stream
/// into the JS-side cache right after construction, leaving that slot
/// empty — so for `new Response(file.stream())` the conversion silently
/// never fires. Callers resolve the stream from either slot (via
/// `get_body_readable_stream`) and pass it in. Returns true when the body
/// value was replaced with the blob.
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
fn try_blob_from_resolved_stream(
&self,
global_object: &JSGlobalObject,
stream: &mut ReadableStream,
) -> bool {
Comment thread
robobun marked this conversation as resolved.
{
let Value::Locked(locked) = self.get_body_value() else {
return false;
};
// Someone is already consuming or waiting on this body.
if locked.promise.is_some()
|| locked.on_receive_value.is_some()
|| !locked.action.is_none()
{
return false;
}
}
// A reader the user holds must keep observing the stream; consumption
// must keep rejecting like the streaming path does.
if stream.is_locked(global_object) {
return false;
}
Comment thread
robobun marked this conversation as resolved.
let Some(blob) = stream.to_any_blob(global_object) else {
return false;
};
self.detach_readable_stream(global_object);
*self.get_body_value() = match blob {
AnyBlob::Blob(b) => Value::Blob(b),
AnyBlob::InternalBlob(b) => Value::InternalBlob(b),
AnyBlob::WTFStringImpl(s) => Value::WTFStringImpl(s),
};
true

Check failure on line 1823 in src/runtime/webcore/Body.rs

View check run for this annotation

Claude / Claude Code Review

Body consumers no longer mark file-backed stream as disturbed

The new fast path consumes the file-backed stream without ever marking the JS `ReadableStream` as disturbed — `to_any_blob()`'s `done()` only cancels the native source, and `detach_readable_stream()` only clears the Response's cached slot. A captured stream reference can therefore be wrapped and consumed again (`const s = Bun.file(p).stream(); await new Response(s).text(); await new Response(s).text();` re-reads the file instead of throwing), which is a regression vs. the pre-PR JS-reader path.
Comment thread
robobun marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
}

/// Zig: `checkBodyStreamRef`. Migrate any `Locked.readable` strong ref
/// into the GC-traced `js.gc.stream` slot to break the cycle (the JS
/// wrapper owns the stream; native side must not hold it strongly).
Expand Down Expand Up @@ -1839,13 +1884,14 @@
}

if matches!(value, Value::Locked(_)) {
if let Some(readable) = self.get_body_readable_stream(global_object) {
if let Some(mut readable) = self.get_body_readable_stream(global_object) {
if readable.is_disturbed(global_object) {
return Ok(handle_body_already_used(global_object));
}
let value = self.get_body_value();
if let Value::Locked(locked) = value {
return locked.set_promise(global_object, Action::GetText, Some(readable));
if !self.try_blob_from_resolved_stream(global_object, &mut readable) {
if let Value::Locked(locked) = self.get_body_value() {
return locked.set_promise(global_object, Action::GetText, Some(readable));
}
}
}
let value = self.get_body_value();
Expand Down Expand Up @@ -1909,14 +1955,16 @@
}

if matches!(value, Value::Locked(_)) {
if let Some(readable) = self.get_body_readable_stream(global_object) {
if let Some(mut readable) = self.get_body_readable_stream(global_object) {
if readable.is_disturbed(global_object) {
return Ok(handle_body_already_used(global_object));
}
let value = self.get_body_value();
value.to_blob_if_possible();
if let Value::Locked(locked) = value {
return locked.set_promise(global_object, Action::GetJSON, Some(readable));
if !self.try_blob_from_resolved_stream(global_object, &mut readable) {
Comment thread
robobun marked this conversation as resolved.
Outdated
if let Value::Locked(locked) = self.get_body_value() {
return locked.set_promise(global_object, Action::GetJSON, Some(readable));
}
}
}
let value = self.get_body_value();
Expand Down Expand Up @@ -1956,18 +2004,20 @@
}

if matches!(value, Value::Locked(_)) {
if let Some(readable) = self.get_body_readable_stream(global_object) {
if let Some(mut readable) = self.get_body_readable_stream(global_object) {
if readable.is_disturbed(global_object) {
return Ok(handle_body_already_used(global_object));
}
let value = self.get_body_value();
value.to_blob_if_possible();
if let Value::Locked(locked) = value {
return locked.set_promise(
global_object,
Action::GetArrayBuffer,
Some(readable),
);
if !self.try_blob_from_resolved_stream(global_object, &mut readable) {
if let Value::Locked(locked) = self.get_body_value() {
return locked.set_promise(
global_object,
Action::GetArrayBuffer,
Some(readable),
);
}
}
}
let value = self.get_body_value();
Expand Down Expand Up @@ -2008,14 +2058,16 @@
}

if matches!(value, Value::Locked(_)) {
if let Some(readable) = self.get_body_readable_stream(global_object) {
if let Some(mut readable) = self.get_body_readable_stream(global_object) {
if readable.is_disturbed(global_object) {
return Ok(handle_body_already_used(global_object));
}
let value = self.get_body_value();
value.to_blob_if_possible();
if let Value::Locked(locked) = value {
return locked.set_promise(global_object, Action::GetBytes, Some(readable));
if !self.try_blob_from_resolved_stream(global_object, &mut readable) {
if let Value::Locked(locked) = self.get_body_value() {
return locked.set_promise(global_object, Action::GetBytes, Some(readable));
}
}
}
let value = self.get_body_value();
Expand Down Expand Up @@ -2149,7 +2201,7 @@
}

if matches!(value, Value::Locked(_)) {
if let Some(readable) = self.get_body_readable_stream(global_object) {
if let Some(mut readable) = self.get_body_readable_stream(global_object) {
let value = self.get_body_value();
let Value::Locked(locked) = value else {
unreachable!()
Expand All @@ -2161,8 +2213,10 @@
return Ok(handle_body_already_used(global_object));
}
value.to_blob_if_possible();
if let Value::Locked(locked) = value {
return locked.set_promise(global_object, Action::GetBlob, Some(readable));
if !self.try_blob_from_resolved_stream(global_object, &mut readable) {
if let Value::Locked(locked) = self.get_body_value() {
return locked.set_promise(global_object, Action::GetBlob, Some(readable));
}
}
}
let value = self.get_body_value();
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/webcore/ReadableStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ impl ReadableStream {
// `store.clone()` carries the +1 that Zig's explicit `blob.store.?.ref()`
// provided after the raw-pointer copy in `initWithStore`.
let blob = Blob::init_with_store(store.clone(), global_this);
// Restore the slice window the FileReader carries (the inverse
// of `from_blob_copy_ref`); `init_with_store` spans the whole
// store, which would serve the entire file for a sliced blob.
if let Some(offset) = blobby.start_offset {
blob.offset.set(offset as webcore::blob::SizeType);
}
if let Some(max_size) = blobby.max_size {
blob.size.set(max_size as webcore::blob::SizeType);
}
// it should be lazy, file shouldn't have opened yet.
debug_assert!(!blobby.started.get());
self.done(global_this);
Expand Down
85 changes: 85 additions & 0 deletions test/js/bun/http/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2396,3 +2396,88 @@ it.if(isPosix)("serves /bun:info over a unix socket in development mode", async
expect(text).toContain("bun_version");
expect(res.status).toBe(200);
});

describe("Response wrapping a Bun.file() stream", () => {
// Position-dependent contents so slice windows are verifiable. 1 MiB: on
// unfixed builds, serving a sliced file stream stalls until idleTimeout
// and resets the connection (the test then fails by timeout).
const STREAM_FILE_SIZE = 1024 * 1024;
function makeStreamFile() {
const bytes = new Uint8Array(STREAM_FILE_SIZE);
for (let i = 0; i < STREAM_FILE_SIZE; i++) bytes[i] = (i * 7) & 0xff;
const dir = tmpdirSync();
const path = join(dir, "serve-file-stream.bin");
writeFileSync(path, bytes);
return { path, bytes };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("serves with Content-Length via the native blob path", async () => {
const { path, bytes } = makeStreamFile();
using server = Bun.serve({
port: 0,
fetch: () => new Response(file(path).stream()),
});

const res = await fetch(server.url);
// an unread file-backed stream takes the same path as Response(file):
// sized body, no chunked encoding
expect(res.headers.get("content-length")).toBe(String(STREAM_FILE_SIZE));
expect(res.headers.get("transfer-encoding")).toBeNull();
const body = await res.bytes();
expect(body.byteLength).toBe(STREAM_FILE_SIZE);
expect(Buffer.compare(body, bytes)).toBe(0);
});

it("serves a sliced file stream with exactly the slice's bytes", async () => {
const { path, bytes } = makeStreamFile();
const start = 100;
const end = 1124;
using server = Bun.serve({
port: 0,
idleTimeout: 5,
fetch: () => new Response(file(path).slice(start, end).stream()),
});

const res = await fetch(server.url);
const body = await res.bytes();
expect(body.byteLength).toBe(end - start);
expect(Buffer.compare(body, bytes.subarray(start, end))).toBe(0);
expect(res.headers.get("content-length")).toBe(String(end - start));
});

it("a stream being read by user JS keeps the streaming error semantics", async () => {
// The locked-stream error is reported as an unhandled error after the
// headers are sent, so this case runs in a subprocess.
const { path } = makeStreamFile();
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
using server = Bun.serve({
port: 0,
fetch: () => {
// lock the stream after constructing the Response: the render
// path must not bypass the existing cannot-pipe error handling
const response = new Response(Bun.file(${JSON.stringify(path)}).stream());
response.body.getReader();
return response;
},
});
const res = await fetch(server.url);
const body = await res.text();
console.log("status:" + res.status + " body-bytes:" + body.length);
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// headers go out, then the locked stream errors and the body is
// truncated; the unhandled error exits the process with 1
expect(stdout).toContain("status:200 body-bytes:0");
expect(stderr).toContain("ReadableStream is locked");
expect(exitCode).toBe(1);
});
});
81 changes: 81 additions & 0 deletions test/js/web/fetch/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,3 +736,84 @@
});
}
});

describe("Response wrapping a Bun.file() stream", () => {
// Position-dependent content so slice windows are verifiable. 1 MiB:
// on unfixed builds, consuming a Response wrapping a sliced stream of a
// file this large never resolves (the test then fails by timeout).
const SIZE = 1024 * 1024;
function makeFile(dir: string) {
const bytes = new Uint8Array(SIZE);
for (let i = 0; i < SIZE; i++) bytes[i] = (i * 7) & 0xff;
const path = `${dir}/body-file-stream.bin`;
require("fs").writeFileSync(path, bytes);
return { path, bytes };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

test(".bytes() returns a Uint8Array with the full contents", async () => {
const { tempDirWithFiles } = require("harness");

Check warning on line 754 in test/js/web/fetch/body.test.ts

View check run for this annotation

Claude / Claude Code Review

Dynamic require() in new tests violates test/CLAUDE.md

nit: per `test/CLAUDE.md` ("Avoid dynamic import & require"), these new tests should use module-scope imports rather than `require("fs")` / `require("harness")` inside test bodies. The file already has `import { bunEnv, bunExe, exampleSite } from "harness"` at the top — add `tempDirWithFiles` there, and add `import { writeFileSync } from "fs"` at module scope, then drop the seven `require(...)` calls in this describe block.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
const dir = tempDirWithFiles("body-file-stream", {});
const { path, bytes } = makeFile(dir);

const result = await new Response(file(path).stream()).bytes();
expect(result).toBeInstanceOf(Uint8Array);
expect(result.byteLength).toBe(SIZE);
expect(Buffer.compare(result, bytes)).toBe(0);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test(".bytes() on a sliced file stream resolves with exactly the slice", async () => {
const { tempDirWithFiles } = require("harness");
const dir = tempDirWithFiles("body-file-stream-slice", {});
const { path, bytes } = makeFile(dir);

const start = 100;
const end = 1124;
const result = await new Response(file(path).slice(start, end).stream()).bytes();
expect(result).toBeInstanceOf(Uint8Array);
expect(result.byteLength).toBe(end - start);
expect(Buffer.compare(result, bytes.subarray(start, end))).toBe(0);
});

test(".text() and .arrayBuffer() on a sliced file stream resolve", async () => {
const { tempDirWithFiles } = require("harness");
const dir = tempDirWithFiles("body-file-stream-text", {});
const path = `${dir}/text.txt`;
require("fs").writeFileSync(path, "0123456789".repeat(100));

const text = await new Response(file(path).slice(10, 30).stream()).text();
expect(text).toBe("01234567890123456789");

const ab = await new Response(file(path).slice(10, 30).stream()).arrayBuffer();
expect(ab.byteLength).toBe(20);
});

test("a disturbed file stream still throws at Response construction", async () => {
const { tempDirWithFiles } = require("harness");
const dir = tempDirWithFiles("body-file-stream-read", {});
const { path } = makeFile(dir);

const stream = file(path).stream();
const reader = stream.getReader();
const first = await reader.read();
expect(first.done).toBe(false);
reader.releaseLock();

expect(() => new Response(stream)).toThrow("ReadableStream has already been used");
});

test("a file stream with a held reader keeps rejecting consumption", async () => {
const { tempDirWithFiles } = require("harness");
const dir = tempDirWithFiles("body-file-stream-locked", {});
const { path } = makeFile(dir);

const response = new Response(file(path).stream());
const reader = response.body!.getReader();
expect(response.body!.locked).toBe(true);
// the held reader must keep observing the stream; bytes() must not
// short-circuit to the blob path
expect(async () => {
await response.bytes();
}).toThrow();
reader.releaseLock();
});
});
Loading