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 3 commits into
Conversation
…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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughModifies ChangesSendfile close_connection handling
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 10:51 PM PT - Jul 7th, 2026
❌ @robobun, your commit c06094b has 1 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: build #70198 finished with 284 lanes green.
Diff is ready for review. |
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.
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.