Repository navigation
Bun.serve: reset a cut HTTP/1.0 streamed body, and drain a finished response on graceful stop() - #46123
Draft
robobun wants to merge 3 commits into
Draft
Bun.serve: reset a cut HTTP/1.0 streamed body, and drain a finished response on graceful stop()#46123robobun wants to merge 3 commits into
robobun wants to merge 3 commits into
Conversation
…esponse on graceful stop() An HTTP/1.0 client reads a streamed body until the connection ends. When the server cut such a body, the connection still ended without an error: a FIN on TCP, and over TLS a close_notify before it. The client took the short body for a whole answer. - HttpResponse::hasIncompleteCloseDelimitedBody() tells from the response state that the close of the connection ends a body that is not all out. - HttpContext::onTimeout (the idle timer, server.timeout()), HttpContext::onEnd (a client that ends its side) and TemplatedApp::close() (server.stop(true)) then close with LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET. us_socket_group_close_all_ex() takes a function that picks the close code of each socket at its close. - HttpResponse::closeIfIdle() does not close a Bun.serve connection whose finished response has not drained. closeIdleConnections() leaves it, and a graceful stop() waits for its reader. node:http is unchanged: Node ends such a body with a FIN.
Collaborator
Author
|
Updated 2:38 PM PT - Oct 21st, 2026
✅ @robobun, your commit f80d282d4579499c25171ca1c587ea9a6d719bef passed in 🧪 To try this PR locally: bunx bun-pr 46123That installs a local version of the PR into your bun-46123 --bun |
Collaborator
Author
|
Status: draft. It waits for CI on macOS and Windows before it leaves draft. How I reproduced it: The test is |
robobun
added a commit
that referenced
this pull request
Oct 21, 2026
#46123 adds the same guard and owns its tests. With both copies a merge keeps the check twice, so this branch drops its copy and the graceful stop() rows that need it.
robobun
added a commit
that referenced
this pull request
Oct 21, 2026
#46123 adds the same check. This branch now makes the same four-line edit, so a merge of the two keeps one copy. The graceful stop() rows are back.
…ccept a reset that drops unread bytes on Windows The row "server.stop() and then the idle timer, the body has ended but has not drained" needs bytes of the body to wait in the server until the idle timer fires. A body of megabyte writes did not do that on macOS and on Windows Server 2019: the kernel took the rest of the body for a client that did not read, and the connection ended in order. The row now puts 64 MB in one write, more than the loopback buffers of Linux and macOS take. The row does not run on Windows, where the amount that the kernel takes depends on the machine. The row "server.stop(true), the body has ended but has not drained" checked the framing of the response after the reset. A reset on Windows drops the bytes that the client has not read, so the client has no header section there.
This branch has not been deployed
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.
Problem
Bun.servebody until the connection ends. When the server cuts the body, the connection still ends without an error.curl --http1.0exits 0 with 12 of 24 bytes.HttpContext::onTimeout: the idle timer,server.timeout()us_socket_group_close_all_ex:server.stop(true)HttpContext::onEnd: a client half-closeHttpResponse::closeIfIdle: a gracefulstop(), before a finished response drainsFix
HttpResponse::hasIncompleteCloseDelimitedBody()reads the response state: HTTP/1.0 request, body begun, no Content-Length, pending or not drained.LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET.us_socket_group_close_all_extakes a function that picks the code of each socket.closeIfIdlewaits for the drain:closeIdleConnections()leaves the connection, and a gracefulstop()waits.test/js/bun/http/serve-close-delimited-framing.test.ts(13 of 27 new rows fail without the fix),bun-server.test.ts,node-http-server-close-drain.test.ts. Also ranserve.test.tsand the node:http suites.Background
SO_LINGER {1,0}, no close_notify. Bun.serve: never terminate a failed body stream as a complete message #40596 ends a failed body with it.onTimeoutalone (misses three sites) and a bit read inus_socket_close(every close pays). node:http is unchanged.closeIfIdleguard.Downsides
stop()waits for a stalled reader until the idle timer, forever atidleTimeout: 0. A Content-Length body already does.controller.close(); server.stop(true)in one call resets a whole HTTP/1.0 body..text+768 bytes, +5 instructions per client-initiated close.Notes
Repro
Bun.serve({ idleTimeout: 1, fetch })where the body is aReadableStreamthat enqueuespart 1 of 2\nand then stays quiet. A raw socket sendsGET / HTTP/1.0. On main the client reads 12 bytes and thenend. With this change it reads 12 bytes and thenECONNRESET.curl --http1.0goes from exit 0 to exit 56.curl --http1.1exits 18 on both builds.What each build does
Release builds of 91e7e3b (the merge base when I measured) and of this branch,
--lto=off, linux x64. HTTP/1.0 client, streamed body without a length. "reset" meansECONNRESETat the client. The TLS column is from a TCP tap that reads TLS 1.2 record types.Connection: keep-aliveserver.timeout(req, 1)server.stop(true),Symbol.disposepullnever settlesstop(true)stop(), reader resumesUnchanged on both builds: a whole HTTP/1.0 answer, an idle keep-alive connection that the timer closes, an HTTP/1.1 chunked body that the timer or
stop(true)cuts (FIN, no last chunk), a body with a Content-Length, a request that has no answer yet, and node:http.node:http
Node v26.3.0 ends these connections in order, and node:http here does the same before and after this change. I ran both cases under Node and under this branch:
res.write()of a first part to an HTTP/1.0 client, thenworker.terminate()on the worker that hosts the server:part 1 of 2\n, thenend.res.end()of 32 MiB to a reader that does not read, thenserver.closeIdleConnections():endafter fewer bytes than the body.Two rows in
node-http-server-close-drain.test.tshold this. They fail when the node:http exemption ofTemplatedApp::close()or ofcloseIfIdleis removed.Behind a proxy
nginx 1.28.0 built from source,
location / { proxy_pass http://127.0.0.1:PORT/; }, the stream cut by the idle timer after 12 bytes:readv() failed (104: Connection reset by peer) while reading upstreamproxy_http_version 1.1upstream prematurely closed connection while reading upstreamnginx changed the default of
proxy_http_versionto 1.1 in 1.29.7. A TCP relay hides the reset: throughsocat TCP-LISTEN TCP,curl --http1.0still exits 0 with 12 bytes on this branch. The docs note says both, and namesproxy_http_version 1.1.Measurements
Base vs PR. Tools: llvm-nm, llvm-objdump, llvm-size, gdb with Python breakpoints, an LD_PRELOAD shim that counts libc socket calls on accepted fds, and
/proc/net/netstatin a private network namespace. strace, perf, valgrind and bloaty are not in the container.HttpResponse::closeIfIdlex2,TemplatedApp::closex2,HttpContext::onEnd<false>x2,us_socket_group_close_all_ex,us_socket_group_close_all,us_loop_close_all_groups.HttpContext::onTimeoutx2 becomesonTimeout<true>x2 (node:http, instruction-identical to base) andonTimeout<false>x2 (Bun.serve). New:HttpContext::closeAllCodex2. node:httponEnd<true>is instruction-identical..text: +768 bytes (llvm-size -A, 77,383,264 to 77,384,032). The stripped file size does not change.onEnd): +5 instructions, +1 branch, +0 calls.stop(true): 1 walk of the HTTP group (base 1), 100 calls of the close-code function for 100 connections, +19 instructions and +1 branch per open connection.uws_app_close: 1 base vs 1 PR. Underuws_app_close_idle: 0 vs 0.setsockopt(SO_LINGER), 1 TLS write fewer (no close_notify). Control rows: 0 syscalls difference over 1,000 whole HTTP/1.0 answers, 1,000 keep-alive GETs, 100 idle reaps, 1,000 client-initiated closes, HTTP/1.1 and Content-Length cuts, node:http cuts.TCPAbortOnData(tcp): +0 across 2,106 closes that are not a hit, +1 per cut close-delimited body (8 of 8 rows).closeIdlesweep per idle socket: +5 instructions, +1 branch tcp. +9 and +2 tls. +0 node:http.sizeof(HttpResponseData)200 to 200,sizeof(us_socket_t)80 to 80. No field is added.stop()with a reader that never resumes: resolves after 12,001 ms with a reset atidleTimeout: 10, still pending after 30 s atidleTimeout: 0. On main a string body with a Content-Length and a stalled reader also keepsstop()pending atidleTimeout: 0(20 s observed).Each clause is covered
I removed each clause of the predicate and each arm in a local build. At least one row fails for each:
stop(true)cuts, that the idle timer cutsstop(true)cutsonTimeoutarmserver.timeout(),stop()then the idle timeronEndarmstop(true)rowscloseIfIdleguardstop()then the idle timer, thebun-server.test.tsrowOn Linux a large file goes out through
sendfile()over TCP and never setsHTTP_WRITE_CALLED. The Content-Length bit is live there over TLS, and on macOS and Windows.The "ended but has not drained" rows write until
write()returns a Promise, and assert that state (backedUp,endParked,pendingRequests). Three of them act in the same turn of the event loop as theend(), so the state holds on every platform.The row "
stop()and then the idle timer" needs the bytes to wait in the server until the timer fires. A body of 1 MiB writes does not wait on macOS and on Windows Server 2019: the kernel takes the whole body for a client that does not read, and the connection ends in order (seen on those test lanes at 2ba661b). The row now puts 64 MiB in one write, more than the loopback buffers of Linux and macOS take. It does not run on Windows, where the amount that the kernel takes depends on the machine.A reset on Windows drops the bytes that the client has not read (seen on the Windows lanes at the same commit). The
stop(true)row of this group does not check the framing there.I ran the rows on linux x64 only. This PR is a draft until CI shows the other platforms.
controller.close()andserver.stop(true)in one callcontroller.close()of a default stream ends the response in a later microtask. Aserver.stop(true)in the same call finds a response that is still pending, and the server cannot tell it from a body that is short. The client has every byte and gets a reset. From a microtask afterclose()the end stays orderly. Two rows hold both orders.Why a function in the walk
server.stop(true)closes the connections one after the other, and the abort listener of one request runs before the next connection closes. A body that begins in that listener has to be judged at its own close. The row "the body begins while it closes another connection" holds this. Without the function its client gets a complete head, 65,536 body bytes and a FIN.What this does not change
Bun.serve({ unix }):SO_LINGERcannot make a reset on AF_UNIX, so a cut still ends as an EOF. Over TLS on a unix socket the close_notify is withheld.process.exit(), SIGTERM, SIGKILL): the kernel sends a FIN.Content-Lengthis still sent without it (Content-Length Header gets overwritten #10507). A kept length would let the client count.fetch()takes a reset for the end of a body without a length.Http2Context::closeIdleon a graceful stop (Bun.serve http2: close a connection gracefully with a FIN, not with close(2) #44874). HTTP/3force_close(Bun.serve(http3): reset the stream when a response body fails mid-body #40598).close_incomplete_stream(Bun.serve: never terminate a failed body stream as a complete message #40596) resets a failed body of any framing. These arms reset HTTP/1.0 bodies without a length only: a client of HTTP/1.1 or of a Content-Length body can tell.Related
closeIfIdleguard as a separateif. The two branches merge without a conflict and leave the guard twice, so one copy has to go when the second lands.maxRequestBodySize.us_socket_group_close_all_ex. This branch has main merged in. The function keeps the loop check and thetick_depthguard of Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021, and takes the close-code parameter.Self-reviewed: 25 concerns raised, 25 addressed.
no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/http/node-http-server-close-drain.test.ts, test/js/bun/http/bun-server.test.ts