Bun.serve: share a route's payload with the Response's unread .body stream
RO
22d ago+351+351+012345678901234567890123456789-50-50-01234567890123456789
Problem
- After
const s = r.bodyon a Responser,Bun.serve({ routes: { "/a": r } })leavesslocked whiler.bodyUsedis false.s.getReader()throwsReadableStream is locked.Bun.write(path, r)fails withERR_BODY_ALREADY_USED. So does aBun.file()body (1.4.2 read the file). - Cause:
StaticRoute::from_jsandFileRoute::from_jscallto_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_blobreturns a Blob over the same store and takes nothing out. - A route still lifts a pipe (two readers would compete).
FileRouterefuses 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
.bodymakes a bodyLocked: 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, asclone()does: the stream the program holds stays spent.
Downsides
- A route over a fetched Response whose
.bodywas 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
.bodywas read (string,HEAD,Uint8Array): the stream is locked whilebodyUsedis 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, thenras a route and one GET, then five uses (reads,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.
- A Response used as a static route after
peek_blobmirrorsto_any_blobsource 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.FileRoutedecides "a second reader would compete" withstore_yields_bytes_once. It does not stat into the store that the program'sBun.file()answers from, sofile.sizekeeps following the file.- Question for a maintainer: must a stream that the program took from
.bodystay 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/plainfor a string body whose.bodywas read. It readswas_stringafter the lift inStaticRoute::from_js, and this PR removes that lift. Whichever lands second takeswas_stringfrom the loader at the peek. - #45601 and #42021 change the Content-Type argument of the header calls in both
from_jsfunctions. The conflict is textual.
- #42064 restores
- Sites that lift without a consume and are not changed here:
formData()with a Content-Type that is not form data (get_form_datainsrc/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) andprotect_for_body_and_render(:4035). On 1.4.3 aGETanswered 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().sizethrough a stat into the shared store (fixed), the fd refusal came after the lift (fixed), aformData()end state and a widerget_content_typewere 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".