usockets(tls): FIN the TCP write side after SSL_shutdown completes on a half-open socket - #34498
Conversation
… a half-open socket us_internal_ssl_shutdown takes the SSL_shutdown() path when SSL_RECEIVED_SHUTDOWN is already set (an allow_half_open TLS server socket whose peer has already FIN'd / sent close_notify). SSL_shutdown() returns 1 (both directions done at the TLS layer) and the function returned without calling us_internal_socket_raw_shutdown, so: - no TCP FIN was sent to the peer (only the close_notify record); - the poll type stayed SOCKET, not SHUT_DOWN; - on epoll the half-open poll had already settled to WRITABLE (then 0 once writable drained) and EPOLLHUP needs both halves FIN'd, so the loop's is_shut_down close path never ran. Every other exit from us_internal_ssl_shutdown already raw-shutdowns; do it here too so shutdown() always half-closes the TCP write side.
|
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 (1)
WalkthroughChangesThe TLS shutdown path now sends the TCP FIN after TLS shutdown completion. A Linux-only HTTPS CONNECT regression test covers half-open TLS socket teardown and verifies event ordering and successful process exit. TLS CONNECT shutdown
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/node/http/node-http-connect.test.ts`:
- Line 609: Update the subprocess wait around proc.stdout.text(),
proc.stderr.text(), and proc.exited to race process completion against an
unref’d timeout that terminates proc; assert that the timeout branch did not
fire, while preserving collection of stdout, stderr, and exitCode on normal
completion.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2eaca298-360f-4f99-bc7c-93d33d6da3a1
📒 Files selected for processing (2)
packages/bun-usockets/src/crypto/openssl.ctest/js/node/http/node-http-connect.test.ts
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
No issues found — the one-line move of us_internal_socket_raw_shutdown out of the ret < 0 branch makes the ret >= 0 path consistent with every other exit from us_internal_ssl_shutdown, and the test nit from the earlier pass was addressed in 0693416. Deferring to a human because this is TLS shutdown ordering in usockets with epoll/kqueue lifecycle implications and ties into #34487; worth a quick look from someone who owns this layer.
Checked: the ret >= 0 path is only reachable with SSL_RECEIVED_SHUTDOWN already set (the !in_init && !RECEIVED_SHUTDOWN guard above returns early otherwise, and in_init returns at 2227), so FIN'ing the write side here cannot truncate readable data. Also confirmed the ret < 0 branch has no early return, so moving the call out preserves its behavior.
Extended reasoning...
Overview
The PR moves a single us_internal_socket_raw_shutdown(s) call in packages/bun-usockets/src/crypto/openssl.c from inside the if (ret < 0) block to after it, so that the TCP FIN is sent on the SSL_shutdown() >= 0 path as well. It adds a Linux-gated https CONNECT regression test that hangs without the fix and exits cleanly with it.
Security risks
This is TLS shutdown ordering. The change does not weaken any verification — it only adds a TCP-level FIN after the TLS close_notify has already been sent on a path where the peer's shutdown was already received. I traced the guards: this branch is only reached when SSL_RECEIVED_SHUTDOWN is set (or SSL_in_init, which returns earlier at line 2227), so there is no risk of FIN'ing the write side while application reads are still expected. No new attack surface.
Level of scrutiny
Medium-high. The diff is tiny and the reasoning in the PR description is thorough and checks out against the surrounding code, but this is native C in the TLS socket lifecycle with platform-specific event-loop coupling (EPOLLHUP vs kqueue/libuv), and it is one half of a two-PR fix alongside #34487. That combination is exactly where a maintainer familiar with usockets internals should confirm the model rather than a bot approving.
Other factors
- All other exit paths in
us_internal_ssl_shutdownalready callus_internal_socket_raw_shutdown; this closes the one gap, which argues strongly for correctness. - The
ret < 0branch has no early return, so hoisting the call is behavior-preserving for the error path. - The earlier inline nit (standalone
expect(stderr).toBe("")) was addressed in 0693416 by folding into a combined.toEqual({...})— confirmed in the current diff. - PR author reports the tls/net/https parallel suites pass with no new failures.
- CodeRabbit suggested cirospaciari as reviewer, which matches ownership of this layer.
|
Self-review probed 34 angles across edge cases, blast radius, and coverage; none survived. The unconditional Diff is green. The new test passes on every Linux lane; no TLS/https/CONNECT failures anywhere. CI on builds 74649 and 74670 reports only pre-existing/flaky failures unrelated to this diff: Ready for a maintainer to merge. |
|
Updated 12:47 PM PT - Jul 17th, 2026
❌ @robobun, your commit ce8d734 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 34498That installs a local version of the PR into your bun-34498 --bun |
What does this PR do?
Problem
us_internal_ssl_shutdownonly takes theSSL_shutdown()path whenSSL_RECEIVED_SHUTDOWNis already set (the!SSL_in_init && !RECEIVED_SHUTDOWNbranch at the top handles the common case with a raw TCP half-close instead). That precondition is met for anallow_half_openTLS server socket whose peer has already half-closed:ssl_on_endsetsSSL_RECEIVED_SHUTDOWNbefore dispatching'end', and the ZERO_RETURN path sets it by receiving the peer's close_notify.With
RECEIVED_SHUTDOWNalready set,SSL_shutdown()sends our close_notify and returns 1. The function then returned without callingus_internal_socket_raw_shutdown, so:SOCKET, notSHUT_DOWN;WRITABLEwhen the peer's FIN was handled, the writable dispatch drops it to 0 once drained, andEPOLLHUP(the only thing reported at events=0) needs both halves FIN'd, so the loop'sis_shut_downclose path never ran.Every other exit from
us_internal_ssl_shutdownalready callsus_internal_socket_raw_shutdown; only theret >= 0path did not.Reachable path
The only sockets that reach the
SSL_shutdown()path after a peer half-close areUWS_HTTP_TLSsockets withallow_half_openset:node:httpsserver CONNECT/Upgrade tunnels (upgradeToTunnelModesetsallow_half_open), where the JS'end'handler'ssocket.end()routes throughhandle.end()→us_socket_shutdown.net.Socket's DuplexautoDestroy: truenormally follows withhandle.close(), which hid this; withautoDestroydisabled the socket never closes.Fix
Call
us_internal_socket_raw_shutdown(s)after theSSL_shutdownbranch regardless ofret, matching the other exit paths. This sends the TCP FIN and moves the poll type toSHUT_DOWN.Relation to #34487
Once the FIN is sent, epoll reports
EPOLLHUP(both halves down) and the socket closes via theis_shut_downeof branch. kqueue and libuv still need thereadable_endedre-arm from #34487 for the poll to re-derive eof at events=0; this PR is the TLS-side prerequisite that gets the FIN on the wire at all.How did you verify your code works?
New test in
test/js/node/http/node-http-connect.test.tsspawns an https CONNECT server withautoDestroydisabled, a TLS client that half-closes first (raw FIN, no close_notify first), and waits for the server socket's'close':Gated to Linux because the close is observed via
EPOLLHUP; kqueue/libuv coverage comes with #34487.Also ran with no new failures vs main:
test/js/node/tls/(159/161),test/js/bun/net/socket.test.ts+test/js/node/net/(249/268), andtest/js/node/test/parallel/test-{tls,https}-*.js(207/210).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-connect.test.ts