Skip to content

Bun.serve: close the connection after a sendfile response when the client asked for close#33698

Open
robobun wants to merge 3 commits into
mainfrom
farm/965a7cb9/sendfile-connection-close
Open

Bun.serve: close the connection after a sendfile response when the client asked for close#33698
robobun wants to merge 3 commits into
mainfrom
farm/965a7cb9/sendfile-connection-close

Conversation

@robobun

@robobun robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.serve never closed the connection after a Connection: close or HTTP/1.0 request when the response body was a Bun.file() whose first sendfile(2) could not complete synchronously. The full body arrived with a correct Content-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 of idleTimeout (forever at idleTimeout: 0).

// 32 MiB so loopback's send buffer forces sendfile into the async path.
await Bun.write(path, Buffer.alloc(32 * 1024 * 1024, 0x62));
const srv = Bun.serve({ port: 0, idleTimeout: 0, fetch: () => new Response(Bun.file(path)) });
// raw TCP: GET / HTTP/1.1 + Connection: close, or GET / HTTP/1.0
// body arrives in full; socket stays open forever (no shutdown(2) in strace).

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 a Uint8Array body close correctly. RFC 9112 §9.6 MUST.

Why

uws_res_end_sendfile (src/uws_sys/libuwsockets.cpp) ignored its close_connection argument: it only set offset, HTTP_END_CALLED, and called markDone(). Every other end path routes through internalEnd, which after markDone() checks HTTP_CONNECTION_CLOSE and issues shutdown() + close() when the socket is uncorked and drained.

On async completion the call arrives from FileResponseStream::on_writableend_sendfileresp.end_send_file(offset, resp.should_close_connection()), inside HttpContext::onWritable. The Rust handler returns false after completing, so onWritable takes the early return at if (!success) return s; and never reaches its own HTTP_CONNECTION_CLOSE check. 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_sendfile now mirrors internalEnd: it records close_connection into HTTP_CONNECTION_CLOSE, and after markDone() clears HTTP_RESPONSE_PENDING it runs the same uncorked + drained check and issues shutdown() + close().

Verification

New Bun.file() sendfile closes connection when requested describe block in test/js/bun/http/bun-serve-file.test.ts: raw TCP request for a 32 MiB Bun.file() body with Connection: close and 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) and serve.test.ts show no new failures.

Related: #33005 fixes the same bug class for the other end paths (internalEnd, uws_res_end_without_body) but does not touch uws_res_end_sendfile; this change is independent of it.

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

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8abdf5f0-df46-457f-ba72-381ffa038dac

📥 Commits

Reviewing files that changed from the base of the PR and between b840e4c and 04b54cb.

📒 Files selected for processing (2)
  • src/uws_sys/libuwsockets.cpp
  • test/js/bun/http/bun-serve-file.test.ts

Walkthrough

Modifies uws_res_end_sendfile in libuwsockets.cpp to honor a close_connection flag by setting HTTP_CONNECTION_CLOSE and conditionally shutting down/closing the underlying socket in both SSL and non-SSL paths. Adds a new test suite validating connection shutdown behavior for Bun.file() sendfile responses.

Changes

Sendfile close_connection handling

Layer / File(s) Summary
SSL and non-SSL close_connection logic
src/uws_sys/libuwsockets.cpp
In both SSL and non-SSL branches of uws_res_end_sendfile, sets HTTP_CONNECTION_CLOSE when close_connection is set, then after HTTP_END_CALLED/markDone/resetTimeout, conditionally calls shutdown() and close() on the AsyncSocket when not corked, no response is pending, and buffered amount is zero.
Socket-level shutdown test
test/js/bun/http/bun-serve-file.test.ts
Adds node:net import and a describe.skipIf(isWindows) suite that uses raw TCP sockets against a Bun.file() sendfile server, verifying full body delivery and proper connection ending (end vs close) for Connection: close and HTTP/1.0 requests.

Possibly related PRs

  • oven-sh/bun#32798: Related change to HTTP client retry logic avoiding replay of sendfile bodies on disconnects, touching the same sendfile/close-connection interaction area.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main fix: closing Bun.serve connections after async sendfile responses when the client requests close.
Description check ✅ Passed The description is detailed and covers the bug, fix, and verification, though its headings differ from the repository template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 7, 2026
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:51 PM PT - Jul 7th, 2026

@robobun, your commit c06094b has 1 failures in Build #70198 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33698

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

bun-33698 --bun

Comment thread test/js/bun/http/bun-serve-file.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_writableend_sendfile → returns falseHttpContext::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: close and HTTP/1.0 variants and assert both the full body length and ended: true (i.e., FIN received via node:net's end event before close).
  • 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.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

On the lifecycle ordering: calling shutdown()/close() from inside callOnWritable is the existing pattern. StaticRoute::on_writableon_writable_bytesresp.try_end(bytes, len, resp.should_close_connection())tryEndinternalEnd already performs the identical shutdown() + close() at the same call depth for buffered bodies (StaticRoute.rs, HttpResponse.h internalEnd). us_socket_close defers the free to the end of the loop iteration via closed_head, and markDone() has already nulled onWritable/onAborted before the close fires, so callOnWritable's epilogue and HttpContext::onWritable's return s; see the same state they already do for the buffered path.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI: build #70198 finished with 284 lanes green. bun-serve-file.test.ts (the new tests) passed on every lane that ran it, including ASAN. The two red lanes are CI infra, unrelated to the diff:

  • :darwin: 26 aarch64 on agent darwin-aarch64-26-5-1-1: buildkite-agent artifact download timed out after 120s, never ran a test. Same agent has failed this way on three consecutive builds (#70047, #70056, #70198).
  • :darwin: 14 aarch64: autobahn.test.ts docker container failed with exec format error (amd64 image on an arm64 host: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8)).

Diff is ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (!isCorkedHTTP_CONNECTION_CLOSE!HTTP_RESPONSE_PENDINGgetBufferedAmount() == 0shutdown()+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 tryEndinternalEnd 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_writableend_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 afterAll now calls rmScope(dir).
  • No bugs from the bug-hunting system on the current revision.
  • The test is well-constructed: it distinguishes graceful FIN (end event → ended: true) from abrupt close, wires error to reject, cleans up the socket in finally, and covers both Connection: close and HTTP/1.0.
  • CI's single red lane was an artifact-download timeout on one darwin agent, unrelated to the change.

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.

1 participant