Keep body stream bookkeeping independent of the body's source

Merged
RO
2mo ago
+483-128

Problem

  • new Response(p.body, p) adopted a JS-backed stream but pulled the Blob out of a blob-backed one (Value::from_js): p came out locked and used, and r.body !== p.body.
  • After text()/json()/blob()/... the body's stream was unlocked and getReader() worked. undici and Chromium keep it locked.
  • Zero-length string/Uint8Array bodies were never used up: blob() left bodyUsed false, and .body handed out a stream the body did not track.

Fix

  • Value::from_js adopts every stream. to_blob_if_possible still 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_consumedAsBody bit on JSReadableStream, part of nativeHandleDetached() and so of isReadableStreamLocked(). set_promise sets it once the consumer has started, to_any_blob after taking a native source's bytes. Bun.readableStreamToText() is unchanged.
  • .body on Empty stores its stream like the other arms, use_() marks Empty used, to_any_blob turns a closed never-read stream into an empty blob, the getters reject a locked stream up front, and Response.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, or Locked (a stream). Reading .body makes a non-null body Locked.
  • Blob- and file-backed streams keep a native source. Until something reads them, to_any_blob can 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 and Value::from_js. If this lands first, #33461 keeps its m_nativeSourceMaterialized gating and drops its getter and from_js hunks on rebase. to_any_blob then wants is_native_source_consumed || is_locked as its guard.
  • ReadableStream__detach/force_detach had no other caller and is removed. m_consumedAsBody takes over both halves of what the -1 handle sentinel did there: the stream reads as locked, and its native handle is neither started by getReader() nor handed to Readable.fromWeb()'s fast path. Unlike the sentinel it leaves m_nativePtr alone, so the handle stays rooted while an async consumer runs.
  • Readable.fromWeb() now throws ERR_INVALID_STATE for 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}ControllerClose only moves a stream to Closed once its queue is empty, so Closed && !disturbed && !locked means 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, and new 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 bodies getReader(); releaseLock(); await r.text() works and bodyUsed stays false while only locked, as in undici. Native-backed bodies still mark themselves disturbed on getReader(); 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 S3 new Response(s3file) redirect used Value::Empty. The spec body is null. With Empty now tracked like any other body they would have become visibly one-shot, so they are Value::Null here (the same three-line change sits in #33125).
  • Bun.readableStreamToText() and the other helpers on a stream a Body already consumed reject with ERR_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.ts asserted ERR_BODY_ALREADY_USED from 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_stream used force_detach; to_any_blob now 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 node test-readable-from-web-* files, regression 07001 and 09555. The failures left in fetch.test.ts/serve.test.ts/bun-server/fetch-backpressure are 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 with origin/main's src/.