Skip to content

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
mainfrom
robobun/5c7eace2/reset-cut-close-delimited-body
Draft

robobun wants to merge 3 commits into
mainfrom
robobun/5c7eace2/reset-cut-close-delimited-body

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • An HTTP/1.0 client reads a streamed Bun.serve body until the connection ends. When the server cuts the body, the connection still ends without an error. curl --http1.0 exits 0 with 12 of 24 bytes.
  • Four sites close this way:
    • HttpContext::onTimeout: the idle timer, server.timeout()
    • us_socket_group_close_all_ex: server.stop(true)
    • HttpContext::onEnd: a client half-close
    • HttpResponse::closeIfIdle: a graceful stop(), before a finished response drains

Fix

  • HttpResponse::hasIncompleteCloseDelimitedBody() reads the response state: HTTP/1.0 request, body begun, no Content-Length, pending or not drained.
  • If it holds, the first three sites close with LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET. us_socket_group_close_all_ex takes a function that picks the code of each socket.
  • closeIfIdle waits for the drain: closeIdleConnections() leaves the connection, and a graceful stop() waits.
  • Verified: 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 ran serve.test.ts and the node:http suites.

Background

Downsides

  • A graceful stop() waits for a stalled reader until the idle timer, forever at idleTimeout: 0. A Content-Length body already does.
  • controller.close(); server.stop(true) in one call resets a whole HTTP/1.0 body.
  • Behind nginx at HTTP/1.0 defaults, its client gets 0 of 12 bytes and an error. Cost: .text +768 bytes, +5 instructions per client-initiated close.
Notes

Repro

Bun.serve({ idleTimeout: 1, fetch }) where the body is a ReadableStream that enqueues part 1 of 2\n and then stays quiet. A raw socket sends GET / HTTP/1.0. On main the client reads 12 bytes and then end. With this change it reads 12 bytes and then ECONNRESET. curl --http1.0 goes from exit 0 to exit 56. curl --http1.1 exits 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" means ECONNRESET at the client. The TLS column is from a TCP tap that reads TLS 1.2 record types.

way the body is cut main, tcp main, tls this PR, tcp this PR, tls
idle timer, quiet stream FIN close_notify, FIN reset no alert, reset
same with Connection: keep-alive FIN close_notify, FIN reset no alert, reset
server.timeout(req, 1) FIN close_notify, FIN reset no alert, reset
server.stop(true), Symbol.dispose FIN close_notify, then reset reset no alert, reset
direct stream whose pull never settles FIN close_notify, FIN reset no alert, reset
the client ends its side mid-body FIN close_notify, FIN reset no alert, reset
body ended, not drained, stop(true) 2,621,384 of 16,777,216 bytes, FIN reset
body ended, not drained, idle timer 2,621,384 bytes, FIN reset
body ended, not drained, graceful stop(), reader resumes 2,621,384 bytes, FIN 16,777,216 bytes, FIN

Unchanged 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, then worker.terminate() on the worker that hosts the server: part 1 of 2\n, then end.
  • res.end() of 32 MiB to a reader that does not read, then server.closeIdleConnections(): end after fewer bytes than the body.

Two rows in node-http-server-close-drain.test.ts hold this. They fail when the node:http exemption of TemplatedApp::close() or of closeIfIdle is 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:

Bun nginx to Bun client of nginx result nginx error.log
main HTTP/1.0 (default) HTTP/1.1 200, 12 bytes, curl exit 0 nothing
this PR HTTP/1.0 (default) HTTP/1.1 200, 0 bytes, curl exit 18 readv() failed (104: Connection reset by peer) while reading upstream
this PR HTTP/1.0 (default) HTTP/1.0 200, 0 bytes, curl exit 0 same
both proxy_http_version 1.1 HTTP/1.1 200, 12 bytes, curl exit 18 upstream prematurely closed connection while reading upstream

nginx changed the default of proxy_http_version to 1.1 in 1.29.7. A TCP relay hides the reset: through socat TCP-LISTEN TCP, curl --http1.0 still exits 0 with 12 bytes on this branch. The docs note says both, and names proxy_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/netstat in a private network namespace. strace, perf, valgrind and bloaty are not in the container.

  • hot paths: 1186 of 1197 uWS and usockets functions instruction-identical (normalized objdump). Changed: HttpResponse::closeIfIdle x2, TemplatedApp::close x2, HttpContext::onEnd<false> x2, us_socket_group_close_all_ex, us_socket_group_close_all, us_loop_close_all_groups. HttpContext::onTimeout x2 becomes onTimeout<true> x2 (node:http, instruction-identical to base) and onTimeout<false> x2 (Bun.serve). New: HttpContext::closeAllCode x2. node:http onEnd<true> is instruction-identical.
  • release .text: +768 bytes (llvm-size -A, 77,383,264 to 77,384,032). The stripped file size does not change.
  • timer reap of a connection that is not a hit: +6 instructions, +1 branch, +0 calls, +0 syscalls. node:http: +0.
  • client-initiated close of a connection that is not a hit (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.
  • allocations on the calling thread under uws_app_close: 1 base vs 1 PR. Under uws_app_close_idle: 0 vs 0.
  • per reset socket: +1 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).
  • closeIdle sweep 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.
  • graceful stop() with a reader that never resumes: resolves after 12,001 ms with a reset at idleTimeout: 10, still pending after 30 s at idleTimeout: 0. On main a string body with a Content-Length and a stalled reader also keeps stop() pending at idleTimeout: 0 (20 s observed).
  • new test rows: 22 run at once, 9 wait for one 4 s sweep of the idle timer (concurrent). The framing file takes 4.2 s on a release build and 11 s on the debug ASAN build.

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:

removed rows that fail
HTTP/1.0 request a chunked body that stop(true) cuts, that the idle timer cuts
body begun a request without an answer that stop(true) cuts
no Content-Length a file body that the idle timer cuts, tls
response pending 9 rows
not drained the two "ended but has not drained" cut rows
onTimeout arm idle timer tcp and tls, server.timeout(), stop() then the idle timer
onEnd arm the client ends its side, tcp and tls
close-code function 5 stop(true) rows
closeIfIdle guard graceful stop tcp and tls, stop() then the idle timer, the bun-server.test.ts row

On Linux a large file goes out through sendfile() over TCP and never sets HTTP_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 the end(), 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() and server.stop(true) in one call

controller.close() of a default stream ends the response in a later microtask. A server.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 after close() 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

Related

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

…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.
@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:38 PM PT - Oct 21st, 2026

✅ @robobun, your commit f80d282d4579499c25171ca1c587ea9a6d719bef passed in Build #130193! 🎉


🧪   To try this PR locally:

bunx bun-pr 46123

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

bun-46123 --bun

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft. It waits for CI on macOS and Windows before it leaves draft.

How I reproduced it: Bun.serve({ idleTimeout: 1, fetch }) returns a ReadableStream that enqueues part 1 of 2\n and then stays quiet. A raw socket sends GET / HTTP/1.0. On main the client reads 12 bytes and then a clean end, and curl --http1.0 exits 0. On this branch the client gets ECONNRESET, and curl --http1.0 exits 56.

The test is test/js/bun/http/serve-close-delimited-framing.test.ts. On main 13 of its 27 new rows fail. On this branch all rows pass.

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

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.

2 participants