Keep body stream bookkeeping independent of the body's source
Merged
RO
2mo ago+483+483+012345678901234567890123456789-128-128-012345678901234567890123456789
Problem
new Response(p.body, p)adopted a JS-backed stream but pulled the Blob out of a blob-backed one (Value::from_js):pcame out locked and used, andr.body !== p.body.- After
text()/json()/blob()/... the body's stream was unlocked andgetReader()worked. undici and Chromium keep it locked. - Zero-length string/
Uint8Arraybodies were never used up:blob()leftbodyUsedfalse, and.bodyhanded out a stream the body did not track.
Fix
Value::from_jsadopts every stream.to_blob_if_possiblestill lifts a blob/file-backed stream back into a blob when the body is consumed, served, or uploaded, so the Content-Length framing stays.- New
m_consumedAsBodybit onJSReadableStream, part ofnativeHandleDetached()and so ofisReadableStreamLocked().set_promisesets it once the consumer has started,to_any_blobafter taking a native source's bytes.Bun.readableStreamToText()is unchanged. .bodyonEmptystores its stream like the other arms,use_()marksEmptyused,to_any_blobturns a closed never-read stream into an empty blob, the getters reject a locked stream up front, andResponse.redirect()/error()get a null body.- Verified:
test/js/web/fetch/body.test.ts(145 new cases, 128 fail on 1.4.3, all checked against node v26.3.0), plus the suites in Notes. Self-reviewed: 4 concerns, 3 addressed, the #33461 overlap is noted below.
Background
- A body is a
Body::Value: string,Blob, bytes,Empty,Null, orLocked(a stream). Reading.bodymakes a non-null bodyLocked. - Blob- and file-backed streams keep a native source. Until something reads them,
to_any_blobcan take the payload back without running the stream. The fetch spec reads a body through a reader it never releases, so a consumed body's stream stays disturbed and locked.
Notes
- Ledger members: #44053 (eager transfer), #44054 and #44171 (unlocked after consume, JS-stream close and error paths), #44055 (zero-length bodies). Not in this PR: #44057 is covered by #33499, and the "
getReader()alone marks a native body used" cascade (#921) by #33461. #44059, #44060 and #44061 are separate mechanisms. - Overlap with #33461: both touch the body getter prologues,
ReadableStream::to_any_blob's guard andValue::from_js. If this lands first, #33461 keeps itsm_nativeSourceMaterializedgating and drops its getter andfrom_jshunks on rebase.to_any_blobthen wantsis_native_source_consumed || is_lockedas its guard. ReadableStream__detach/force_detachhad no other caller and is removed.m_consumedAsBodytakes over both halves of what the-1handle sentinel did there: the stream reads as locked, and its native handle is neither started bygetReader()nor handed toReadable.fromWeb()'s fast path. Unlike the sentinel it leavesm_nativePtralone, so the handle stays rooted while an async consumer runs.Readable.fromWeb()now throwsERR_INVALID_STATEfor any locked stream before it does anything else, as Node does (Node acquires the reader at that point). Before, a locked native-backed stream had its handle taken anyway.ReadableStream__isClosedUnread:ReadableStream{Default,Byte}ControllerCloseonly moves a stream toClosedonce its queue is empty, soClosed && !disturbed && !lockedmeans the stream can never yield a byte. This keeps a touched empty body (new Response(""); r.body) framing and typing exactly like an untouched one, andnew Response(new Blob([]))takes the same path.- A locked (not disturbed) body stream now rejects from the getter with
TypeError: Invalid state: ReadableStream is locked, the same error the C++ helper produced before, and no longer records a pending read first. For JS-stream bodiesgetReader(); releaseLock(); await r.text()works andbodyUsedstays false while only locked, as in undici. Native-backed bodies still mark themselves disturbed ongetReader(); that is #33461's subject. fetch()upload framing: a blob/bytes-backed or closed-empty stream body goes out with a Content-Length (as 1.4.3 did for the blob case through the eager transfer, and as undici does for bodies whose source it knows). A file-backed stream keeps streaming chunked, as today, because its length may not be knowable (FIFO, device). A JS stream streams chunked.Response.redirect(),Response.error()and the S3new Response(s3file)redirect usedValue::Empty. The spec body is null. WithEmptynow tracked like any other body they would have become visibly one-shot, so they areValue::Nullhere (the same three-line change sits in #33125).Bun.readableStreamToText()and the other helpers on a stream a Body already consumed reject withERR_INVALID_STATE"ReadableStream has already been used" (a stream held by someone else's reader still says "is locked").test/js/web/streams/readable-stream-blob-consumed.test.tsassertedERR_BODY_ALREADY_USEDfrom the old blob-loader path and is updated; its point (no crash, a rejected promise) is unchanged.- Rebased onto #42053: its
take_blob_from_unread_streamusedforce_detach;to_any_blobnow marks the stream consumed itself. - Suites run on the debug build:
body.test.ts,body-stream.test.ts(9086),body-clone,body-mixin-errors,body-async-iterator,body-stream-excess,serve.test.ts,bun-server,bun-serve-static,bun-serve-file,bun-serve-body-json-async,serve-if-none-match,proxy.test.ts,cookie.test.ts,html-rewriter.test.js,bun-write.test.js,spawn-stdin-readable-stream,streams.test.js,readable-stream-blob-consumed,native-source-onclose-leak,sync-pull-fast-path,request.test.ts,response.test.ts,client-fetch,content-length,fetch.stream,fetch.test.ts,fetch-abort-stream-body,fetch-keepalive,fetch-backpressure,node-stream.test.js,direct-readable-stream, the nodetest-readable-from-web-*files, regression 07001 and 09555. The failures left infetch.test.ts/serve.test.ts/bun-server/fetch-backpressureare environment-only here (IPv6, running as root, no internet or S3 egress, ASAN timeouts, and the ASAN RSS bound in "bounds memory when a handler forwards req.body") and reproduce withorigin/main'ssrc/.