Skip to content

Close a streamed HTTP response on Bun when the client asked for it (fixes hung iterable REST responses) - #2351

Open
kriszyp wants to merge 4 commits into
mainfrom
kris/2349-bun-iterable-close
Open

Close a streamed HTTP response on Bun when the client asked for it (fixes hung iterable REST responses)#2351
kriszyp wants to merge 4 commits into
mainfrom
kris/2349-bun-iterable-close

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 26, 2026

Copy link
Copy Markdown
Member

Under Bun, a streamed HTTP response delivered its full body and then never closed the connection, so a client that asked for a close was left attached until its own timeout — a finite iterable REST response was the shape #2210 reported, and the two integration arms #2070 had to gate are un-gated again by this fix.

Bun's node:http never derives keep-alive from the request: shouldKeepAlive stays true for a Connection: close request, and neither a Connection: close response header, response.socket.end(), nor response.destroy() closes the connection — all three measured as no-ops. Ending the request's socket is the one remedy that works, so pipeBodyToResponse() now does that after a clean stream finish for the shapes that are close-delimited: an explicit Connection: close on either HTTP version, and HTTP/1.0 without both an explicit keep-alive and a Content-Length. That is the same line Node draws. It is isBun-gated, HTTP/1-only, and clean-path-only — pipeline() already destroys the response on error, which closes the connection on both runtimes.

Worth recording for whoever debugs Bun next, and now in DESIGN.md: this is a node:http-emulation defect rather than a Bun.serve one because the main HTTP port on Bun is served by node:http, not Bun.serve. onWebSocket() calls getHTTPServer() unconditionally (the DESIGN.md note recording this) (it has a uWS branch and no Bun branch, since Bun native WebSockets are unimplemented), and MQTT registers WS on the default port before REST's httpServer() call, so httpServers[port] is already a Node server by the time getBunHTTPServer runs and it returns without registering a serve config. Only the exclusive operations port reaches Bun.serve. That is a separate, larger issue, not this PR.

Fixes #2210. Resolves the decision in #2349 — it is the product bug it appeared to be, so the suite is un-gated rather than kept skipped.

For the human reviewer

  1. Carry the workaround in Harper, or file it upstream and stay gated? Chosen: fix it here, because the Bun shard is otherwise red and Bun leaves finite iterable REST connections open after Connection: close #2210 blocks it. The cost is a Bun-gated branch with no expiry marker — nothing will prompt us to delete it when Bun fixes its emulation. If you want an upstream issue filed and referenced from the code, say so and I'll open one.
  2. HTTP/1.0 parity with Node, or just always close on 1.0 under Bun? Chosen: parity — close-delimit 1.0 unless the client sent keep-alive and the response got a Content-Length. That exemption is the one path with no test arm (the fixture has no sized streamed body; a blob GET is the real-world shape). It is measured, not assumed: the hasHeader('content-length') check returns true under Bun after the writeHead(status, headers) fast path this file uses, and both runtimes then serve two responses on one 1.0 keep-alive socket. "Always close on 1.0" is a one-line simplification that costs a keep-alive optimization almost no modern client asks for — a reasonable call to make differently.
  3. Reaching through the response to request.socket. A response-piping helper now ends the connection, which is a layering choice other paths could copy. It rests on the measurement that the response-layer alternatives are no-ops under Bun, and a destroyed-socket guard keeps a peer RST from turning the pipeline callback into an uncaughtException; those are local runs on Bun 1.3.14, recorded in DESIGN.md.
  4. Clean streamed path only. Non-streamed Bun responses were measured separately and do close correctly on response.end() (with and without a final chunk), so the call site is deliberately narrow rather than "everywhere a Bun HTTP/1 response completes".

Verification

Route: existing integration spec, un-gated and extended — integrationTests/server/stream-error-contract.test.ts, which drives a raw net.Socket so the close mechanism is visible on the wire.

  • Fails on base, with origin/main sources and a forced rebuild: the two Bun arms time out at 15s each (control: IterHealth iterable-rest and iterable-rest: mid-stream throw). With the fix they complete in ~11ms and ~6ms.
  • Both new HTTP/1.0 arms are load-bearing: deleting only the httpVersionMinor branch makes both hang for 15s under Bun while the other 10 arms pass.
  • The new keep-alive arm is load-bearing in the other direction: making the HTTP/1.1 path close unconditionally fails only that arm.
  • Full spec after the rebase: Bun 13/13, Node 13/13, uWS 11/11 + 2 skips (the HTTP/1.0 arms — uWS never routes an HTTP/1.0 request to the resource).
  • A 45-test Bun slice of the HTTP-response-path suites (SSE event data, cache headers, security headers, caching, operations server) passes.
  • Two reviewed risks measured and not reproduced: socket.end() truncates nothing (8 MB over plain TCP and 6 MB over TLS to a deliberately slow reader each arrive whole, terminal chunk included), and the hasHeader check is not inert under Bun.

Not covered: HTTP/2 (excluded by the guard — no per-request socket), and the HTTP/1.0 keep-alive-with-Content-Length exemption (judgment call 2 above).

Complexity: medium

Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=2 @ 58380fc

Human-Review-Need: 3 @ 58380fc

Kris Zyp and others added 2 commits August 26, 2026 11:48
…or it

Bun's node:http never derives keep-alive from the request: for a `Connection: close`
request `shouldKeepAlive` stays true, and neither a `Connection: close` response header
nor `response.socket.end()` closes the connection. A response ended by the stream
machinery (an async source drained through `pipeline()`) therefore delivered its full
body and terminal chunk and then left the client attached until its own timeout, which
is what #2210 observed as a hung finite iterable REST response.

`pipeBodyToResponse()` now ends the request's socket after a clean stream finish when the
client sent `Connection: close` — the only remedy that works under Bun. It is isBun-gated
and HTTP/1-only; Node closes such connections itself, and the error path already closes
because `pipeline()` destroys the response.

The two Bun arms #2070 gated in the stream-error contract are un-gated again, so the Bun
shard covers this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…alive direction

Review follow-ups on the Bun close fix:

- An explicit `close` token now wins on both HTTP versions, and HTTP/1.0 falls back to
  close-by-default: 1.0 persistence needs an explicit `keep-alive` AND a length to read
  to, so a 1.0 response that got no `Content-Length` is close-delimited — the same line
  Node draws (Node closes it at ~7ms; Bun hung indefinitely). A 1.0 keep-alive request
  whose response did get a `Content-Length` is left open, matching Node, which Bun then
  handles correctly. Two new arms cover the close cases; they skip on uWS, which never
  routes an HTTP/1.0 request to the resource.
- A new arm pins the other direction: a keep-alive client is left connected and reuses
  the socket. Nothing asserted that before, so a change that closed every connection
  would have passed the whole suite.
- Treat a non-string `Connection` header as absent instead of calling `.split()` on it,
  so an unexpected shape from Bun's emulation cannot throw inside the pipeline callback
  after the body was already written.

Two reviewed risks were measured and did not reproduce: `socket.end()` truncates nothing
(8 MB over plain TCP and 6 MB over TLS to a deliberately slow reader each arrive whole,
because the end is graceful), and `hasHeader('content-length')` is not inert under Bun's
`writeHead(status, headers)` fast path (Bun populates it; Node does not, and the branch
is Bun-only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kriszyp kriszyp added this to the v5.3 milestone Aug 26, 2026
@kriszyp
kriszyp requested review from dawsontoth and heskew August 26, 2026 18:11

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses a divergence in Bun's node:http emulation where connections are left open after a response stream ends. It introduces endConnectionIfClientExpectsClose in server/http.ts to gracefully close the request socket when a client expects a close or under HTTP/1.0 conditions. Additionally, integration tests are updated to verify HTTP/1.0 behavior and keep-alive socket reuse. Feedback on the changes suggests handling cases where the connection header might be parsed as an array of strings to ensure robust token parsing.

Comment thread server/http.ts
@kriszyp
kriszyp requested review from ldt1996 and removed request for dawsontoth August 26, 2026 23:02
@kriszyp
kriszyp marked this pull request as ready for review August 26, 2026 23:02
kriszyp and others added 2 commits August 26, 2026 19:48
Bring the review workflow callers up to the exact default-branch content so the Claude action can exchange its OIDC token and run on this PR.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Distinguish the error-bearing pipeline destroy that aborts a response from the clean post-end response methods that Bun treats as no-ops.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun leaves finite iterable REST connections open after Connection: close

1 participant