Bun.serve: close the connection after a sendfile response when the client asked for close - #33698
Bun.serve: close the connection after a sendfile response when the client asked for close#33698robobun wants to merge 2 commits into
Conversation
WalkthroughModifies ChangesSendfile close_connection handling
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 12:55 AM PT - Jul 11th, 2026
❌ @robobun, your commit 74c8f26 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 33698That installs a local version of the PR into your bun-33698 --bun |
There was a problem hiding this comment.
Thanks for addressing the cleanup nit. The change mirrors internalEnd's close sequence verbatim and looks correct to me, but since it adds shutdown()/close() inside the onWritable callback path for the sendfile completion, I'd like a human to confirm the socket-lifecycle ordering.
Extended reasoning...
Overview
The PR modifies uws_res_end_sendfile in src/uws_sys/libuwsockets.cpp (both SSL and non-SSL branches) to honour its previously-ignored close_connection argument: it now records HTTP_CONNECTION_CLOSE into the response state and, after markDone(), runs the same uncorked + no-pending + drained → shutdown()/close() sequence that internalEnd and four other sites in HttpResponse.h/HttpContext.h already use. A new describe block in test/js/bun/http/bun-serve-file.test.ts sends a raw-TCP Connection: close request and an HTTP/1.0 request for a 32 MiB Bun.file() body and asserts the full body arrives followed by FIN.
Security risks
None apparent. The change only affects when the server issues FIN/close after a completed sendfile response; it does not touch parsing, auth, or untrusted input. The added logic is gated behind the same four conditions used everywhere else in uWS for connection-close.
Level of scrutiny
Medium-high. The added code is a verbatim copy of a well-established pattern (five existing occurrences across HttpResponse.h:157-166, HttpResponse.h:223-232, HttpResponse.h:742-750, HttpContext.h:441-449, HttpContext.h:537-545), so the risk of the logic itself being wrong is low. However, this is Bun.serve connection-lifecycle code in the C++ uWS FFI layer — a hot, production-critical path where an incorrect close() from inside an onWritable callback could cause UAF, double-close, or hung connections. The PR description traces the callback chain (FileResponseStream::on_writable → end_sendfile → returns false → HttpContext::onWritable early-returns s) and I verified that internalEnd is already invoked from the same position via tryEnd, so there is precedent for closing from here — but a maintainer familiar with usockets' close semantics should confirm.
Other factors
- My previous nit (32 MiB temp dir not cleaned up in
afterAll) was addressed in 04b54cb and the thread is resolved. - The bug-hunting system found no issues on the current revision.
- Tests cover both
Connection: closeand HTTP/1.0 variants and assert both the full body length andended: true(i.e., FIN received via node:net'sendevent beforeclose). - The change is small and self-contained, but does not fall into the "simple, mechanical, or obvious" bucket — it's a behavioural change to socket teardown ordering.
|
On the lifecycle ordering: calling |
|
CI (post-rebase onto 18cfc1a): build #71818 finished with 284 lanes green.
Diff is ready for review. Previous runs (pre-rebase)#70198: 284 green; red lanes were agent |
There was a problem hiding this comment.
No issues found and the nit is addressed — deferring only because this adds a shutdown()/close() call inside the uWS onWritable path, which is socket-lifecycle code worth a human sanity check.
Extended reasoning...
Overview
This PR fixes uws_res_end_sendfile in src/uws_sys/libuwsockets.cpp to honour its previously-ignored close_connection argument: it now sets HTTP_CONNECTION_CLOSE and, after markDone(), runs the same uncorked/no-pending/drained check as internalEnd and issues shutdown() + close(). Both SSL and non-SSL branches are updated identically. A new test suite in test/js/bun/http/bun-serve-file.test.ts uses raw TCP to verify a 32 MiB Bun.file() response delivers the full body and then sends FIN for both Connection: close and HTTP/1.0.
I verified the added block against internalEnd in packages/bun-uws/src/HttpResponse.h:157-168 — it is a byte-for-byte match of the four-guard nesting (!isCorked → HTTP_CONNECTION_CLOSE → !HTTP_RESPONSE_PENDING → getBufferedAmount() == 0 → shutdown()+close()). The author's lifecycle-ordering comment is consistent with what I read: markDone() nulls the callbacks first, us_socket_close defers the free via closed_head, and tryEnd→internalEnd already performs this exact sequence at the same call depth for buffered bodies.
Security risks
None identified. This does not touch auth, crypto, or input parsing. The only behavioural change is that connections which the client already asked to close now actually close after the body is delivered — strictly a resource-leak fix, not a new attack surface.
Level of scrutiny
Medium-high. Bun.serve is production-critical, and adding a close() inside the onWritable callback path is the class of change where an ordering mistake becomes a use-after-free. The mitigations are strong — the added code is a verbatim copy of an existing, exercised pattern from internalEnd, the PR description traces the exact call chain (FileResponseStream::on_writable → end_sendfile → here, inside HttpContext::onWritable), and CI passed on 281 lanes including ASAN — but socket lifecycle in uWS is subtle enough that a maintainer familiar with this layer should confirm.
Other factors
- My earlier nit (32 MiB temp dir not cleaned up) was addressed in 04b54cb; the
afterAllnow callsrmScope(dir). - No bugs from the bug-hunting system on the current revision.
- The test is well-constructed: it distinguishes graceful FIN (
endevent →ended: true) from abrupt close, wireserrorto reject, cleans up the socket infinally, and covers bothConnection: closeand HTTP/1.0. - CI's single red lane was an artifact-download timeout on one darwin agent, unrelated to the change.
…ient asked for close uws_res_end_sendfile ignored its close_connection argument and never ran the shutdown+close check that internalEnd performs. When a Bun.file() body was large enough that sendfile(2) could not complete synchronously, completion happened inside HttpContext::onWritable, whose early return on callOnWritable() == false skips the HTTP_CONNECTION_CLOSE check. The full body arrived but no FIN followed, so an HTTP/1.0 client or one that sent Connection: close waited for EOF forever and the server pinned the fd for the life of idleTimeout. uws_res_end_sendfile now mirrors internalEnd: it records the close flag and, once markDone has cleared HTTP_RESPONSE_PENDING, shuts down and closes the socket when uncorked and drained.
c06094b to
74c8f26
Compare
There was a problem hiding this comment.
No issues found; the earlier temp-dir cleanup nit is addressed. Deferring to a human because this adds shutdown()/close() inside the uWS onWritable path — HTTP server socket lifecycle is critical enough to warrant a maintainer's eyes even though the change looks correct.
Checked: the new guard in uws_res_end_sendfile mirrors internalEnd (HttpResponse.h:157-168) exactly — same isCorked/HTTP_CONNECTION_CLOSE/HTTP_RESPONSE_PENDING/getBufferedAmount sequence.
Checked: the caller FileResponseStream::end_sendfile runs detach_resp() before this, so uWS callbacks are already nulled when close() fires.
Checked: the new test wires error to reject, awaits FIN via end vs close, and cleans up its 32 MiB temp dir.
Extended reasoning...
Overview
The PR fixes uws_res_end_sendfile in src/uws_sys/libuwsockets.cpp to honor its previously-ignored close_connection argument. It now sets HTTP_CONNECTION_CLOSE and, after markDone(), runs the same uncorked/drained check as internalEnd to issue shutdown() + close(). Both SSL and non-SSL branches receive the identical change. A new raw-TCP test in bun-serve-file.test.ts covers Connection: close and HTTP/1.0 against a 32 MiB Bun.file() body.
Security risks
None identified. This is server-side connection-close semantics; it does not touch auth, input parsing, or trust boundaries. The only risk class is memory safety (premature socket free), addressed under scrutiny below.
Level of scrutiny
High. uws_res_end_sendfile is called from FileResponseStream::on_writable inside HttpContext::onWritable — adding a close() here means the socket may be queued for free while the Rust caller and uWS's onWritable epilogue are still on the stack. The author's lifecycle argument checks out against source: (1) the guard sequence is byte-identical to internalEnd at HttpResponse.h:157-168; (2) StaticRoute::on_writable → tryEnd → internalEnd already performs the same shutdown()/close() at the same call depth for buffered bodies, so this is not a new pattern; (3) us_socket_close defers the actual free via closed_head; (4) FileResponseStream::end_sendfile calls detach_resp() first, so onWritable/onAborted are already nulled. That said, uWS socket lifecycle is exactly the area CLAUDE.md flags as most-blocked in review ("anything that can run user JS can synchronously free your state", ref/deref bracketing around teardown), and I don't think a bot should be the final sign-off on it.
Other factors
- CI build #70198: 284 lanes green including ASAN; the two red lanes are documented infra failures unrelated to the diff.
- My earlier nit (leaked 32 MiB temp dir) was fixed in 74c8f26 —
afterAllnow usesrmScope(dir). - The bug hunting system found nothing.
- Related PR #33005 touches the same bug class for other end paths; a maintainer coordinating that work should confirm the two changes compose.
|
This landed on main through #37074. It added a Checked by running this PR's version of Nothing left for this PR to add, so closing it. |
What
Bun.servenever closed the connection after aConnection: closeor HTTP/1.0 request when the response body was aBun.file()whose firstsendfile(2)could not complete synchronously. The full body arrived with a correctContent-Length, but no FIN followed, so a client waiting for EOF hung forever and the server pinned the fd and uWS response state for the life ofidleTimeout(forever atidleTimeout: 0).Any
Bun.file()response large enough to exceed the socket send buffer takes this path, so a >=1 MiB file to a non-loopback client is affected. The identical bytes sent as aUint8Arraybody close correctly. RFC 9112 §9.6 MUST.Why
uws_res_end_sendfile(src/uws_sys/libuwsockets.cpp) ignored itsclose_connectionargument: it only setoffset,HTTP_END_CALLED, and calledmarkDone(). Every other end path routes throughinternalEnd, which aftermarkDone()checksHTTP_CONNECTION_CLOSEand issuesshutdown()+close()when the socket is uncorked and drained.On async completion the call arrives from
FileResponseStream::on_writable→end_sendfile→resp.end_send_file(offset, resp.should_close_connection()), insideHttpContext::onWritable. The Rust handler returnsfalseafter completing, soonWritabletakes the early return atif (!success) return s;and never reaches its ownHTTP_CONNECTION_CLOSEcheck. No shutdown is ever issued. (Synchronous completion happens inside the request handler's cork, whose uncork path does the check, which is why small files were fine.)Fix
uws_res_end_sendfilenow mirrorsinternalEnd: it recordsclose_connectionintoHTTP_CONNECTION_CLOSE, and aftermarkDone()clearsHTTP_RESPONSE_PENDINGit runs the same uncorked + drained check and issuesshutdown()+close().Verification
New
Bun.file() sendfile closes connection when requesteddescribe block intest/js/bun/http/bun-serve-file.test.ts: raw TCP request for a 32 MiBBun.file()body withConnection: closeand with HTTP/1.0, asserting the full body arrives and the server then sends FIN. Both hang on the system bun (5 s timeout) and pass with this change. The full file (69 tests) andserve.test.tsshow no new failures.Related: #33005 fixes the same bug class for the other end paths (
internalEnd,uws_res_end_without_body) but does not touchuws_res_end_sendfile; this change is independent of it.no test proof · iteration 8 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/bun-serve-file.test.ts