Skip to content

Bun.serve: do not end a body stream that a reader holds, and refuse it on HEAD as on GET - #43973

Draft
robobun wants to merge 2 commits into
robobun/a6bb0e75/file-body-stream-and-headfrom
robobun/e4fa84d4/head-locked-stream-error
Draft

robobun wants to merge 2 commits into
robobun/a6bb0e75/file-body-stream-and-headfrom
robobun/e4fa84d4/head-locked-stream-error

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Draft: a maintainer must decide on the first Downsides bullet. The base is #41585.

Problem

  • Bun.serve ends a body stream that another reader holds. The producer's next enqueue() throws Invalid state: Controller is already closed. Regression since 1.4.0.
  • For that stream, GET calls error() with ERR_STREAM_CANNOT_PIPE. HEAD answers 200.
  • cancel_unread_body (src/runtime/server/RequestContext.rs:740) and the HEAD renderer (:2662) have no lock check. GET's refusal (:3146) leaves the stream where teardown finds it.

Fix

  • GET and HEAD ask one predicate, can_send_body_stream, before they write a header.
  • refuse_body_stream detaches the refused stream. cancel_unread_body skips a locked stream: ReadableStream.prototype.cancel() rejects on one.
  • Verified: 46 of 88 tests in serve-reused-response.test.ts and serve-pending-promise-abort-leak.test.ts fail at the base. Self-reviewed: 6 and 7 concerns raised for the two commits, all addressed.

Background

Downsides

  • A handler that reads the body only on HEAD gets 500 for HEAD, not 200.
  • A handler can abandon a reader on its body. Then 300 HEAD requests leave 300 producers open, not 0.
  • Per HEAD, 204 or 304 with a stream body: one more ReadableStream__isLocked call (HEAD: 12 more instructions). GET does not change. Release function bytes: +81 B and +323 B for the two commits.
Notes

Stack. The base of this PR is #41585 (HEAD for file bodies and for used or errored bodies). This PR has two commits. The first commit stops the server from ending a stream that another reader holds. It was #43972, and it also applies to main without #41585. The second commit adds the HEAD refusal. I rebase onto main when #41585 merges.

Reproduction, first commit. Same result on 1.4.2 and on main (29d9638):

const enc = new TextEncoder();
let controller;
const stream = new ReadableStream({ start(c) { controller = c; c.enqueue(enc.encode("part1;")); } });
const responses = { "/a": new Response(stream), "/b": new Response(stream) };
using server = Bun.serve({ port: 0, fetch: req => responses[new URL(req.url).pathname] });

const get = await fetch(new URL("/a", server.url));
const reader = get.body.getReader();
await reader.read();                                         // "part1;"
await fetch(new URL("/b", server.url), { method: "HEAD" });  // cancels the stream GET /a is sending
controller.enqueue(enc.encode("part2;"));                    // TypeError: Invalid state: Controller is already closed

Which release broke what (released binaries, Linux x64):

                                                    1.3.14   1.4.0    1.4.1    1.4.2    main     this PR
HEAD while another response sends the stream        ok       broken   broken   broken   broken   ok
a dropped late result around that stream            ok       ok       broken   broken   broken   ok
204 with a reader that the handler holds            ok       ok       broken   broken   broken   ok
a refused GET, another client's fetch() download    (1)      ok       broken   broken   broken   ok

(1) 1.3.14 does not refuse the second Response. It answers 200 and the first download stalls.

The callers. The callers of cancel_unread_body get the rule with no change of their own: do_render_null_body_status_corked (101/103/204/205/304) and discard_response_body (a handler result that arrives after the client aborted, after server.stop(true), after an upgrade, or when the connection closed during dispatch). The Locked arm of do_render_head_response asks can_send_body_stream first, so it calls cancel_unlocked_body directly.

GET's refusal. GET answers a locked stream body through error() with ERR_STREAM_CANNOT_PIPE. With no error() handler, or one that returns nothing, the request keeps the refused Response until it ends. release_body_stream (at finalize) and on_abort look the stream up through the Response and end it. A handler that holds upstream.body.getReader() and returns upstream read 1 of 6 chunks. It now reads 6. With an error() handler that returns a Response, main already reads 6, so the tests use the default 500 and run in a child process.

The refusal also no longer calls stream.value.unprotect(). No protect() of a stream value exists in src/runtime/server/ or src/runtime/webcore/, so the call had nothing to balance.

Reproduction, second commit. Same output on 1.4.2 and on main (29d9638):

const mk = () => new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode("body")); c.close(); } });
for (const method of ["GET", "HEAD"]) {
  const errors = [];
  using srv = Bun.serve({ port: 0,
    fetch() { const r = new Response(mk()); r.body.getReader(); return r; },
    error(e) { errors.push(e.code); return new Response("handled", { status: 500 }); } });
  const res = await fetch(srv.url, { method });
  console.log(method, res.status, res.headers.get("transfer-encoding"), JSON.stringify(errors));
}
// GET  500 null ["ERR_STREAM_CANNOT_PIPE"]
// HEAD 200 chunked []          (this PR: HEAD 500 null ["ERR_STREAM_CANNOT_PIPE"])

The decision. It applies to the second commit only. The first commit changes no status.

The server sees only the Response that the handler returned for HEAD. The second commit assumes that GET gets a Response in the same state. That holds for a shared or cached stream. It does not hold for a handler that treats HEAD in its own way:

async fetch(request) {
  const response = new Response(stream);
  if (request.method === "HEAD") {
    const reader = response.body.getReader();
    let length = 0;
    for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) length += chunk.value.byteLength;
    response.headers.set("x-length", String(length));
  }
  return response; // main: GET 200, HEAD 200. This PR: GET 200, HEAD 500.
}

The handler gets the old result when it returns new Response(null, response) for HEAD.

Nobody reported the HEAD result as a bug. It came from a review note on #41585.

What the tests cover, first commit. The lock comes in three kinds, and the cross-request tests run each: a reader (ReadableStream), the sink of a type: "direct" stream, and the native pipe of a fetch() body. Each kind counts the cancel() calls of its source. For the fetch() kind that is the stream of the upstream server. With the guard alone and no detach, the two GET tests fail and the other 13 pass, so each change has a test that needs it.

What the tests cover, second commit. 8 locked shapes (getReader() on start, pull and type: "direct" sources, with a Content-Length header, stream.getReader() without .body, tee(), await response.text(), another Response around the same stream) x a Response, a fulfilled promise and a pending promise. Each test runs GET and HEAD, pins what GET does (status 500, content-length: 7, one error() call, zero source.cancel() calls, the lock holder reads the whole stream), and asserts that HEAD gives the same result with no body. So the tests fail when GET changes and HEAD does not follow. More tests: an any-method route, a HEAD route and a HEAD derived from a GET route, HTTP/2, the same Response returned twice (ERR_STREAM_CANNOT_PIPE then ERR_BODY_ALREADY_USED for every order of GET and HEAD), and the default 500 with one child process per method and an exact count of error reports.

With src/ at the base, 34 of 49 tests fail in serve-reused-response.test.ts and 12 of 39 in serve-pending-promise-abort-leak.test.ts. 15 of them belong to the first commit.

serve.test.ts has no new test on purpose. Two of its tests fail in my environment with and without the change, so a run of that file cannot show a pass.

Measurements, first commit. The base for these numbers is main at 29d9638. Release builds unless a line says otherwise.

source.cancel() calls on a stream that somebody else holds:

                                          main   first commit
HEAD, reader held by the handler          1      0
204 / 205 / 304, GET and HEAD, held       1      0      (6 rows)
late result around a stream in flight     1      0
HEAD, fresh stream, 8 requests            8/8    8/8
204 GET, fresh stream, 8 requests         8/8    8/8

Stream FFI calls per request (gdb breakpoint hit counts over 100 requests from curl, debug build, the N=0 control is 0):

                         isLocked     taggedStream   cancelWithReason
HEAD, fresh stream       2 -> 3       4 -> 4         1 -> 1
GET, fresh stream        4 -> 4       8 -> 8         0 -> 0
GET / HEAD, string       0 -> 0       0 -> 0         0 -> 0
204 GET, fresh stream    2 -> 3       4 -> 4         1 -> 1
204 HEAD, fresh stream   1 -> 2       2 -> 2         1 -> 1
204 GET, held reader     2 -> 3       4 -> 4         1 -> 0

Instructions that each function executes per call, callees stepped over (gdb nexti, HTTP/1 production copy): GET with a string body 347 -> 347 and with a stream body 348 -> 348, summed over on_response, protect_for_body_and_render, render, do_render_with_body and render_bytes. HEAD with a stream body 185 -> 185 in on_response and do_render_head_response.

Release size (llvm-nm --print-size, summed over distinct addresses, and size): cancel_unread_body 795 -> 836 B at one address, do_render_with_body +5 B in each of 8 copies. .text stays at 80,856,413 B and the stripped binary at 80,995,912 B.

JS objects retained after Bun.gc(true) are equal with and without the change: after 1000 fresh-stream HEADs ReadableStream 0 -> 2 and protectedObjectCount 4 -> 4.

Wire capture: a raw-socket client sent GET and HEAD for 18 body shapes. With the Date header removed, all 36 response heads and bodies are byte-identical.

A handler that releases or cancels its reader before it returns leaves 0 producers open after 300 requests (HEAD, and GET with a 204). Only a reader that the handler abandons gives the 300 of Downsides.

Measurements, second commit. The base for these numbers is #41585 plus the first commit. Release builds unless a line says otherwise.

Release size (llvm-nm --print-size, summed over distinct addresses, and size):

do_render_with_body        8 copies   20880 -> 19200 B   (-210 B per copy)
do_render_head_response    8 copies   14692 -> 15480 B   (+98 B per copy)
cancel_unread_body         1 copy       836 ->   579 B
cancel_unlocked_body       1 copy         0 ->   815 B   (new)
refuse_body_stream         1 copy         0 ->   657 B   (new)
all function bytes                                        +323 B
.text                              80,691,636 -> 80,691,636
stripped binary                    80,832,032 -> 80,832,032

An earlier shape kept cancel_unlocked_body as a method of the generic impl. LLVM then inlined the small cancel_unread_body into all 8 copies of do_render_null_body_status_corked, and the total was +4041 B. The shared parts are free #[inline(never)] functions for that reason. The generic_body_not_generic count of mordant for RequestContext.rs is 7 before and after each commit.

Instructions that each function executes per call, callees stepped over (gdb nexti, HTTP/1 production copy):

                              GET string   GET stream   HEAD string   HEAD stream
on_response                   105 -> 105   105 -> 105   110 -> 110    110 -> 110
protect_for_body_and_render    33 ->  33    66 ->  66
render                         49 ->  49    49 ->  49
do_render_with_body            87 ->  87   131 -> 131
render_bytes                   54 ->  54
do_render_head_response                                 130 -> 130     86 ->  98
sum                           328 -> 328   351 -> 351   240 -> 240    196 -> 208

Stream FFI calls per request. In a release build a HEAD with a stream body makes one lock check and one stream lookup, as with the first commit alone: do_render_head_response calls ReadableStream__isLocked once and cancel_unlocked_body does not call it (disassembly). A breakpoint count is not possible there, because LTO inlines the callee. In a debug build the debug_assert! in cancel_unlocked_body adds one isLocked call per HEAD, 204 or 304 response with a stream body (3 -> 4, gdb hit counts over 100 requests). taggedStream and cancelWithReason do not change. GET and string bodies do not change.

Write and send syscalls per HEAD response (gdb catch syscall, 100 requests): fresh stream 1 -> 1, locked stream 1 -> 1. The error() Response of a refused HEAD leaves in one write.

Wire capture: a raw-socket client sent GET and HEAD for 18 body shapes. With the Date header removed, the base and this PR give identical response heads and bodies on 18 of 18 GET rows and 11 of 18 HEAD rows. The 7 HEAD rows that differ are the locked-stream rows: 200 with transfer-encoding: chunked becomes 500 with content-length: 7.

Removed after review. An earlier shape had one more commit: a microtask checkpoint in the HEAD branch of render(), which only error() Responses reach. It made HEAD match GET when a microtask from error() locks the stream. It also copied two GET defects to HEAD: a held fetch() body ended after 1 of 4 chunks, and an unread stream was not cancelled. #43971 tracks that window for both methods. Until then HEAD differs from GET in that one case: GET answers the default 500, HEAD answers the status of the error() Response.

Other differences between HEAD and GET for a stream body, not in this PR.

Also not in this PR.

Other suites on the debug build: bun-serve-file.test.ts, serve-direct-readable-stream.test.ts, bun-server.test.ts, serve-error-handler-stream.test.ts, serve-stream-body-error.test.ts, serve-http2.test.ts, serve-http3.test.ts, bun-serve-static.test.ts and bun-serve-routes.test.ts pass. serve.test.ts: 323 pass, 2 fail with and without the change in my environment (the root-range port test, because the container runs as root, and the /bun:info loopback test, because an egress proxy answers the non-loopback request). fetch.stream.test.ts: 6 or 7 tests (Content-Length response works (multiple parts)) time out at 5 s in a run of the whole file, with and without the change, on a host with a load average of 90. They pass when they run alone.


[human-review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 46 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/http/serve-pending-promise-abort-leak.test.ts test/js/bun/http/serve-reused-response.test.ts
bun test v1.4.3 (367d939d9)

test/js/bun/http/serve-reused-response.test.ts:
(pass) returning a Response with an already-used body > returning the same string-bodied Response twice calls the error handler [47.62ms]
(pass) returning a Response with an already-used body > returning the same Uint8Array-bodied Response twice calls the error handler [18.17ms]
(pass) returning a Response with an already-used body > returning the same stream-bodied Response twice calls the error handler [21.87ms]
(pass) returning a Response with an already-used body > returning another Response around a ReadableStream that was already sent calls the error handler [38.58ms]
(pass) returning a Response with an already-used body > returning another Response around a type: "direct" ReadableStream that was already sent calls the error handler [18.12ms]
242 |           return { ...response, errors, rest: await held.rest(), cancels };
243 |         }
244 | 
245 | 
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (9b940dce8)

test/js/bun/http/serve-reused-response.test.ts:
(pass) returning a Response with an already-used body > returning the same string-bodied Response twice calls the error handler [3.22ms]
(pass) returning a Response with an already-used body > returning the same Uint8Array-bodied Response twice calls the error handler [0.81ms]
(pass) returning a Response with an already-used body > returning the same stream-bodied Response twice calls the error handler [0.88ms]
(pass) returning a Response with an already-used body > returning another Response around a ReadableStream that was already sent calls the error handler [1.42ms]
(pass) returning a Response with an already-used body > returning another Response around a type: "direct" ReadableStream that was already sent calls the error handler [0.83ms]
(pass) returning a Response with an already-used body > a stream body that a reader already holds > body.getReader() on a start() source > returned as a Response: HEAD reports what GET reports [2.47ms]
(pass) returning a Response with an already-used body > a stream body that a reader already holds > body.getReader() on a start() source > ret
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/http/serve-pending-promise-abort-leak.test.ts test/js/bun/http/serve-reused-response.test.ts
bun test v1.4.3 (367d939d9)

test/js/bun/http/serve-reused-response.test.ts:
(pass) returning a Response with an already-used body > returning the same string-bodied Response twice calls the error handler [47.86ms]
(pass) returning a Response with an already-used body > returning the same Uint8Array-bodied Response twice calls the error handler [18.69ms]
(pass) returning a Response with an already-used body > returning the same stream-bodied Response twice calls the error handler [28.91ms]
(pass) returning a Response with an already-used body > returning another Response around a ReadableStream that was already sent calls the error handler [36.60ms]
(pass) returning a Response with an already-used body > returning another Response around a type: "direct" ReadableStream that was already sent calls the error handler [18.50ms]
(pass) returning a Response with an already-used body > a stream body that a reader already holds > body.getRea
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 815ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited
[2/20] rustc bun_install 
[3/20] rustc bun_jsc 
[4/20] rustc bun_sys_jsc 
[5/20] rustc bun_ast_jsc 
[6/20] rustc bun_semver_jsc 
[7/20] rustc bun_patch_jsc 
[8/20] rustc bun_bundler_jsc 
[9/20] rustc bun_css_jsc 
[10/20] rustc bun_sourcemap_jsc 
[11/20] rustc bun_js_parser_jsc 
[12/20] rustc bun_http_jsc 
[13/20] rustc bun_install_jsc 
[14/20] rustc bun_sql_jsc 
[15/20] rustc bun_runtime 
[16/20] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/vendor/mimalloc/src/static.c.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-299c5323879e79af-lto/lib/libJavaScriptCore.a(UnifiedSource-bytecompiler-1.cpp.o at 42235090)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../
... (truncated)
diff hotspot
src/runtime/server/RequestContext.rs               |  82 +++--
 .../http/serve-pending-promise-abort-leak.test.ts  | 212 ++++++++++++
 test/js/bun/http/serve-reused-response.test.ts     | 383 ++++++++++++++++++++-
 3 files changed, 651 insertions(+), 26 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/runtime/server/RequestContext.rs                         16     11     62
…st/js/bun/http/serve-pending-promise-abort-leak.test.ts      3      5     39
test/js/bun/http/serve-reused-response.test.ts                8      5     44

@robobun

robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on 1.4.2 and on main (29d9638).

  • Two Responses wrap one ReadableStream. While a GET streams the first Response, a HEAD for the second cancels the stream. The producer's next enqueue() throws Invalid state: Controller is already closed. A 204, a 304 and a handler result that arrives after the client aborted do the same.
  • const r = new Response(stream); r.body.getReader(); return r: GET answers 500 through error() with ERR_STREAM_CANNOT_PIPE. HEAD answers 200 with transfer-encoding: chunked.

This PR is a draft. The second commit makes one working handler shape fail (see Downsides), so it waits for a maintainer's decision on the premise that it shares with #41585.

This is the one PR for this work. #43972 is closed, and its commit is the first commit here.

Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
@robobun
robobun force-pushed the robobun/e4fa84d4/head-locked-stream-error branch from 1e21973 to 9b940dc Compare September 25, 2026 11:10
A locked stream belongs to its lock holder: a reader the handler took, or
the server's own sink for another Response around the same stream. Two
places ended such a stream.

cancel_unread_body cancels the body stream of a Response the server does
not transmit: a HEAD response, a 101/204/205/304 response, and a handler
result the server drops. It now cancels only a stream that is not locked.
ReadableStream.prototype.cancel rejects on a locked stream for the same
reason.

GET refuses a locked stream body with ERR_STREAM_CANNOT_PIPE. The refusal
left the stream attached to the Response, so the teardown of the refused
request found it and ended it. The refusal now detaches the stream. It
also no longer calls unprotect() on the stream, which no protect() matched.
… does

GET refuses a Response whose body stream a reader already holds: it calls
error() with ERR_STREAM_CANNOT_PIPE. The HEAD renderer had no such check and
answered the handler's status with transfer-encoding: chunked.

can_send_body_stream is the one place both renderers ask whether a stream
body can be sent. refuse_body_stream builds the error() argument and sets
the state a refusal leaves: the body is used and the Response lets go of the
stream without a cancel.

The HEAD arm asks before it writes any header. It hands the stream it
resolved to cancel_unlocked_body, so a HEAD with a sendable stream resolves
the stream once and asks for the lock once. The shared parts are free
functions that are not inlined, so the eight RequestContext
monomorphizations share one copy.
@robobun
robobun force-pushed the robobun/e4fa84d4/head-locked-stream-error branch from 9b940dc to b995e28 Compare September 25, 2026 12:17
@robobun

robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:46 AM PT - Sep 25th, 2026

✅ @robobun, your commit b995e28d43081c5c17c7c1a3b9756c4a35cab6e9 passed in Build #120603! 🎉


🧪   To try this PR locally:

bunx bun-pr 43973

That installs a local version of the PR into your bun-43973 executable, so you can run:

bun-43973 --bun

@robobun robobun changed the title Bun.serve: HEAD sends a locked ReadableStream body to error(), as GET does Bun.serve: do not end a body stream that a reader holds, and refuse it on HEAD as on GET Sep 25, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant