Bun.serve: share a route's payload with the Response's unread .body stream

RO
22d ago
+351-50

Problem

  • After const s = r.body on a Response r, Bun.serve({ routes: { "/a": r } }) leaves s locked while r.bodyUsed is false. s.getReader() throws ReadableStream is locked. Bun.write(path, r) fails with ERR_BODY_ALREADY_USED. So does a Bun.file() body (1.4.2 read the file).
  • Cause: StaticRoute::from_js and FileRoute::from_js call to_blob_if_possible() to look at the payload. Since #42516 that lift closes and locks the stream, and a route never reads it.

Fix

  • A route shares the payload with the stream. The new ReadableStream::peek_blob returns a Blob over the same store and takes nothing out.
  • A route still lifts a pipe (two readers would compete). FileRoute refuses a file descriptor before any lift.
  • Verified: test/js/bun/http/bun-serve-static.test.ts (29 new cases, 27 fail on main).
  • Self-reviewed: 7 concerns raised, 6 addressed (notes).

Background

  • Reading .body makes a body Locked: its payload sits behind a native stream. A lift takes the payload out as a Blob and spends the stream.
  • Two Blobs over one store read the same bytes. A file path is opened again for each read.
  • Considered a lift plus a re-point of the cached .body, as clone() does: the stream the program holds stays spent.

Downsides

  • A route over a fetched Response whose .body was read copies the received bytes once.
  • Size: +2,048 bytes of text (release, linux x64, size).
  • Still open: the same lift in a failed formData() (#44999) and in a handler's 204 or 304 Response.
Notes
  • Two reduced programs from the reports, on this branch and on the released 1.4.3 line:
    • A Response used as a static route after .body was read (string, HEAD, Uint8Array): the stream is locked while bodyUsed is false in 3 of 3 cases on 1.4.3, in 0 of 3 here.
    • const r = new Response(Bun.file(p)), s = r.body, then r as a route and one GET, then five uses (read s, s.tee(), new Response(s).text(), Bun.write(path, r), r.text()): 4 of 5 are refused on 1.4.3, all 5 give the whole file here. The same holds for a string body.
  • peek_blob mirrors to_any_blob source by source. A Blob store and a lazy file store are shared by reference. A fully received network body is copied. A closed unread JS stream is the empty Blob. Anything else is not peeked, and the route lifts or refuses as before.
  • FileRoute decides "a second reader would compete" with store_yields_bytes_once. It does not stat into the store that the program's Bun.file() answers from, so file.size keeps following the file.
  • Question for a maintainer: must a stream that the program took from .body stay the body after the look (this PR)? The other design lifts and then re-points .body (about 15 lines, and it covers every look site), but the stream the program holds stays spent.
  • Open PRs that touch the same lines:
    • #42064 restores text/plain for a string body whose .body was read. It reads was_string after the lift in StaticRoute::from_js, and this PR removes that lift. Whichever lands second takes was_string from the loader at the peek.
    • #45601 and #42021 change the Content-Type argument of the header calls in both from_js functions. The conflict is textual.
  • Sites that lift without a consume and are not changed here:
    • formData() with a Content-Type that is not form data (get_form_data in src/runtime/webcore/Body.rs). #44999 fixes it with no behaviour change. #44969 proposes Fetch's end state (the body is used up), which is breaking.
    • A handler's Response: the HEAD framing check (src/runtime/server/RequestContext.rs:2582) and protect_for_body_and_render (:4035). On 1.4.3 a GET answered with status 204 or 304 leaves {"locked":true,"bodyUsed":false}. Neither #43973 nor #41585 removes that lift. It is reported separately.
  • Self-review concerns: the route froze Bun.file().size through a stat into the shared store (fixed), the fd refusal came after the lift (fixed), a formData() end state and a wider get_content_type were in this PR (removed), the overlapping PRs were not named (named above), the HEAD site was said to be covered by #43973 (corrected above). Open: the question above.
  • Suites run on the debug build: bun-serve-static, bun-serve-static-stress-access-body, bun-serve-static-stress-no-body, bun-serve-file, bun-serve-routes, bun-serve-headers, serve-reused-response, serve-file-slice-read-error, bun-server, body, body-clone. Failures seen were 5 s timeouts on the debug build in tests that do not use routes: bun-serve-file.test.ts "pollable Bun.file(path) ... resumes when the client reads", bun-server.test.ts "server.stop() drain promise" (12 concurrent child processes, each passes alone), body.test.ts "string body consumption does not leak".