Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
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
40 changes: 33 additions & 7 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2401,6 +2401,15 @@
// not content-length or transfer-encoding so we need to respect the body
let body_value = response.get_body_value();
body_value.to_blob_if_possible();
// `to_blob_if_possible` can't see streams migrated into the JS-side
// cached slot; convert blob/file-backed streams here too so HEAD
// reports the same Content-Length the GET render path produces.
if matches!(body_value, Body::Value::Locked(_)) {
if let Some(mut readable) = response.get_body_readable_stream(global_this) {
let _ = response.try_blob_from_resolved_stream(global_this, &mut readable);
}
}
let body_value = response.get_body_value();

Check failure on line 2412 in src/runtime/server/RequestContext.rs

View check run for this annotation

Claude / Claude Code Review

HEAD on sliced file-stream Response reports wrong Content-Length

The HEAD-parity fix in 9844fd77 routes `new Response(Bun.file(p).slice(a,b).stream())` through the `Value::Blob` arm, which calls `blob.resolve_size()` — and `resolve_size()`'s File arm unconditionally clobbers the slice's concrete `size` with `store_size - offset`. So for a 1 MiB file with `.slice(100, 1124)`, HEAD now emits `Content-Length: 1048476` while GET (via `do_sendfile`, which saves `original_size` before stat) correctly emits `Content-Length: 1024` — the opposite of the HEAD↔GET parit
Comment thread
robobun marked this conversation as resolved.
match body_value {
Body::Value::InternalBlob(_) | Body::Value::WTFStringImpl(_) => {
let mut blob = body_value.use_as_any_blob_allow_non_utf8_string();
Expand Down Expand Up @@ -2909,11 +2918,29 @@
return;
}
// 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::Blob(_) | 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::Direct => {
readable_stream::Source::JavaScript | readable_stream::Source::Direct => {
if let Some(resp) = this.resp {
let mut pair = StreamPair { stream, this };
resp.run_corked_with_type(Self::do_render_stream, &raw mut pair);
Expand Down Expand Up @@ -2942,9 +2969,8 @@
// we can avoid streaming it and just send it all at once.
if byte_stream.has_received_last_chunk.get() {
let mut byte_list = byte_stream.drain();
this.blob = AnyBlob::from_array_list(
byte_list.move_to_list_managed(),
);
this.blob =
AnyBlob::from_array_list(byte_list.move_to_list_managed());
this.response_body_readable_stream_ref.deinit();
this.do_render_blob();
return;
Expand Down
129 changes: 100 additions & 29 deletions src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1778,6 +1778,51 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized {
}
}

/// 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
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 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized {
}

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,14 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized {
}

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) {
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 +2002,18 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized {
}

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 +2054,14 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized {
}

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 @@ -2091,6 +2137,12 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized {

let value = self.get_body_value();
if let Value::Locked(_locked) = value {
// Unlike the other consumers, this one must NOT take
// `try_blob_from_resolved_stream`: the parse below reads
// `blob.slice()` synchronously, and a converted file-backed blob
// has no in-memory bytes yet — there is no async
// file-read-then-parse path here (same in the Zig original, where
// `Response(Bun.file(p)).formData()` has the same limitation).
let owned_readable = self.get_body_readable_stream(global_object);
// PORT NOTE: reshaped for borrowck — re-borrow after self method call.
let value = self.get_body_value();
Expand Down Expand Up @@ -2149,7 +2201,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized {
}

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 @@ -2160,9 +2212,10 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized {
{
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 Expand Up @@ -2519,9 +2572,27 @@ impl<'a> ValueBufferer<'a> {
webcore::readable_stream::Source::Invalid => {
return Err(bun_core::err!("InvalidStream"));
}
// toBlobIfPossible should've caught this
webcore::readable_stream::Source::Blob(_)
| webcore::readable_stream::Source::File(_) => unreachable!(),
| webcore::readable_stream::Source::File(_) => {
// `run`'s `to_blob_if_possible` only consults the native
// `Locked.readable` slot, so blob/file-backed streams that
// `check_body_stream_ref` migrated into the JS-side cache
// land here. Convert them now and re-dispatch through the
// Blob arm (buffered bytes / async file read).
let mut stream = stream;
if let Some(blob) = stream.to_any_blob(self.global) {
*value = match blob {
AnyBlob::Blob(b) => Value::Blob(b),
AnyBlob::InternalBlob(b) => Value::InternalBlob(b),
AnyBlob::WTFStringImpl(s) => Value::WTFStringImpl(s),
};
// the stream's source is consumed and detached; no
// reason to keep the JS wrapper rooted
let _ = core::mem::take(&mut self.readable_stream_ref);
return self.run(value, None);
}
return Err(bun_core::err!("UnsupportedStreamType"));
}
webcore::readable_stream::Source::JavaScript
| webcore::readable_stream::Source::Direct => {
// this is broken right now
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/webcore/ReadableStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,25 @@ 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);
// The FileReader keeps its lazy store (the blob above only
// clones it), so without this a captured stream reference
// could be wrapped into a new Response and re-read the
// file from disk. Mark the JS stream disturbed and drop
// its native ptr — the exact state the JS streaming path
// used to leave a consumed file stream in.
self.force_detach(global_this);
return Some(webcore::blob::Any::Blob(blob));
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/webcore/Response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,15 @@ impl Response {
<Self as BodyMixin>::detach_readable_stream(self, global_object)
}

#[inline]
pub fn try_blob_from_resolved_stream(
&self,
global_object: &JSGlobalObject,
stream: &mut super::readable_stream::ReadableStream,
) -> bool {
<Self as BodyMixin>::try_blob_from_resolved_stream(self, global_object, stream)
}

#[inline]
pub fn set_size_hint(&self, size_hint: super::blob::SizeType) {
if let BodyValue::Locked(locked) = self.body.get().value_mut() {
Expand Down
Loading
Loading