Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions src/runtime/api/bun/spawn/stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,18 @@ impl Stdio {
// `value` is on the stack. `dupe()` only bumps the store refcount.
return out_stdio.extract_blob(global, webcore::blob::Any::Blob(blob.dupe()), i);
} else if let Some(req) = value.as_class_ref::<webcore::Request>() {
// `extract_body_value`'s `to_blob_if_possible` can't see streams
// that `check_body_stream_ref` migrated into the JS-side cache;
// convert blob/file-backed streams here so they take the blob
// path instead of the streaming stdin consumer.
if let Some(mut readable) = req.get_body_readable_stream(global) {
let _ = req.try_blob_from_resolved_stream(global, &mut readable);
}
return Self::extract_body_value(out_stdio, global, i, req.get_body_value(), is_sync);
} else if let Some(res) = value.as_class_ref::<webcore::Response>() {
if let Some(mut readable) = res.get_body_readable_stream(global) {
let _ = res.try_blob_from_resolved_stream(global, &mut readable);
}
return Self::extract_body_value(out_stdio, global, i, res.get_body_value(), is_sync);
}

Expand Down
42 changes: 35 additions & 7 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2393,6 +2393,15 @@ where
// 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();
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 @@ -2894,11 +2903,31 @@ where
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. Reader-held streams
// were already rejected by the `is_locked` check
// above.
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;
}
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 @@ -2927,9 +2956,8 @@ where
// 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
42 changes: 34 additions & 8 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,10 @@ pub trait BlobExt {
raw_bytes: *mut [u8],
) -> JsResult<JSValue>;
/// # Safety
/// `buf` must be valid for reads for the duration of the call.
unsafe fn to_form_data_with_bytes<const _L: Lifetime>(
/// `buf` must be valid for reads for the duration of the call; when
/// `LIFETIME == Temporary` it must be a leaked default-allocator
/// `Box<[u8]>` whose ownership transfers to this call.
unsafe fn to_form_data_with_bytes<const LIFETIME: Lifetime>(
&self,
global: &JSGlobalObject,
buf: *mut [u8],
Expand Down Expand Up @@ -2392,7 +2394,17 @@ impl BlobExt for Blob {
let store_size = file.max_size;
let offset = self.offset.get();
self.offset.set(store_size.min(offset));
self.size.set(store_size.saturating_sub(offset));
let available = store_size.saturating_sub(self.offset.get());
// Matches the Bytes arm: only resolve an unknown size. A
// slice already has a concrete `size`; overwriting it with
// `store_size - offset` would widen the view to the end of
// the file. Clamp a known size to `available` so it can't
// report past EOF.
if self.size.get() == MAX_SIZE {
self.size.set(available);
} else {
self.size.set(self.size.get().min(available));
}
return;
}

Expand Down Expand Up @@ -2447,8 +2459,17 @@ impl BlobExt for Blob {
let file = store.data_mut().as_file();
if file.seekable.is_some() && file.max_size != MAX_SIZE {
let store_size = file.max_size;
let offset = self.offset.get();
return (store_size.min(offset), store_size.saturating_sub(offset));
let offset = store_size.min(self.offset.get());
let available = store_size.saturating_sub(offset);
// Matches `resolve_size`: a known size (a slice) is
// authoritative; only an unknown size falls back to the
// remainder of the file, clamped so it can't pass EOF.
let size = if self.size.get() == MAX_SIZE {
available
} else {
self.size.get().min(available)
};
return (offset, size);
}
if file.seekable == Some(false) {
return (self.offset.get(), self.size.get());
Expand Down Expand Up @@ -3010,11 +3031,15 @@ impl BlobExt for Blob {
///
/// # Safety
/// `buf` must be valid for reads for the duration of the call.
unsafe fn to_form_data_with_bytes<const _L: Lifetime>(
unsafe fn to_form_data_with_bytes<const LIFETIME: Lifetime>(
&self,
global: &JSGlobalObject,
buf: *mut [u8],
) -> JSValue {
// Reclaim `Temporary` bytes (a leaked default-allocator `Box<[u8]>`
// handed over by the read path) after the parse below — including on
// the invalid-encoding early return.
let _free = (LIFETIME == Lifetime::Temporary).then(|| TemporaryBytes(buf));
let Some(encoder) = self.get_form_data_encoding() else {
return ZigString::init(b"Invalid encoding").to_error_instance(global);
};
Expand Down Expand Up @@ -3290,8 +3315,9 @@ impl BlobExt for Blob {
return Ok(jsc::DOMFormData::create(global));
}
// SAFETY: `view_ptr` is the store-backed view from `shared_view_raw`;
// `to_form_data_with_bytes` only reads it.
Ok(unsafe { self.to_form_data_with_bytes::<{ Lifetime::Temporary }>(global, view_ptr) })
// `to_form_data_with_bytes` only reads it. `Share` (not `Temporary`):
// the bytes belong to the store and must not be reclaimed.
Ok(unsafe { self.to_form_data_with_bytes::<{ Lifetime::Share }>(global, view_ptr) })
}
#[inline]
fn get<const MOVE: bool, const REQUIRE_ARRAY: bool>(
Expand Down
Loading
Loading