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
Open
Close a streamed HTTP response on Bun when the client asked for it (fixes hung iterable REST responses)#2351kriszyp wants to merge 4 commits into
kriszyp wants to merge 4 commits into
Conversation
…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>
Contributor
There was a problem hiding this comment.
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.
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>
Contributor
|
Reviewed; no blockers found. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:httpnever derives keep-alive from the request:shouldKeepAlivestaystruefor aConnection: closerequest, and neither aConnection: closeresponse header,response.socket.end(), norresponse.destroy()closes the connection — all three measured as no-ops. Ending the request's socket is the one remedy that works, sopipeBodyToResponse()now does that after a clean stream finish for the shapes that are close-delimited: an explicitConnection: closeon either HTTP version, and HTTP/1.0 without both an explicitkeep-aliveand aContent-Length. That is the same line Node draws. It isisBun-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 anode:http-emulation defect rather than aBun.serveone because the main HTTP port on Bun is served bynode:http, notBun.serve.onWebSocket()callsgetHTTPServer()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'shttpServer()call, sohttpServers[port]is already a Node server by the timegetBunHTTPServerruns and it returns without registering a serve config. Only the exclusive operations port reachesBun.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
keep-aliveand the response got aContent-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: thehasHeader('content-length')check returnstrueunder Bun after thewriteHead(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.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 inDESIGN.md.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 rawnet.Socketso the close mechanism is visible on the wire.origin/mainsources and a forced rebuild: the two Bun arms time out at 15s each (control: IterHealth iterable-restanditerable-rest: mid-stream throw). With the fix they complete in ~11ms and ~6ms.httpVersionMinorbranch makes both hang for 15s under Bun while the other 10 arms pass.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 thehasHeadercheck 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-Lengthexemption (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