Conversation
A closed client WebSocket leaves its socket open until a read sees the peer's FIN. A paused socket never reads, and JS cannot reach resume() once the close is dispatched, so the file descriptor stayed open for the life of the process. usockets parks a paused socket out of epoll on the EPOLLHUP that the client's own shutdown raises. send_close_with_body now resumes the socket before it writes the Close frame. pause() returns false while a Close frame waits to be flushed, so that the socket stays resumed until the close completes.
|
Warning Review limit reached
On-demand reviews are free for the next 4 days. After that, they cost $0.25 per reviewed file. Or wait 6 minutes for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (3)
Comment |
|
Updated 2:46 PM PT - Sep 16th, 2026
❌ @robobun, your commit 96582e7 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 42974That installs a local version of the PR into your bun-42974 --bun |
|
Status: ready for review. The diff is green in CI. The one red job is not from this change (see CI below). How I reproduced it: the loop from the report (open a client
The new tests: CI (build 116740, finished): 180 of 181 jobs pass. |
|
Cross-reference: #42978 changes This PR adds a window that #42978 does not cover on main. While a Close frame waits behind unsent data, C++ still holds the native client, The PR that merges second needs one more change: |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the new self.resume() in send_close_with_body for synchronous teardown: us_socket_resume can call us_internal_socket_close_raw when us_poll_change fails, which reaches handle_close, but WebSocket::close holds a RefPtr guard on this and handle_close takes outgoing_websocket, so the follow-up dispatch_abrupt_close/clear_data in send_close_with_body are no-ops rather than a double dispatch or use-after-free. The other caller (peer Close frame parsed inside the read callback) runs under the socket's own data callback, where uSockets already tolerates a close.
Extended reasoning...
Two verified findings are posted inline (per-test timeouts on the new fixture tests, and m_paused/isPaused drifting from the socket state when pause() returns false during a pending close dispatch). This note only records what else was examined: the reentrancy risk of calling resume() from send_close_with_body, traced through packages/bun-usockets/src/socket.c:863-881 (us_socket_resume closes the socket on us_poll_change failure) and src/http_jsc/websocket_client.rs (close at line 1381 holds RefPtr::from_this(this); handle_close at line 287 takes outgoing_websocket so later dispatch_abrupt_close returns early). The only behavioral consequence on that failure path is a 1006 instead of a 1000 close, which is confined to an epoll re-registration failure. Not approving because the change alters socket lifecycle in the WebSocket client and the inline findings, one of which affects user-visible isPaused state, warrant a human look.
4 verified lower-impact observations (convention, logging or cleanup points) were not posted.
The fd tests now run in the test process and compare the set of open file descriptors, so they need no subprocess and no per-test timeout. The docs now say what isPaused reports.
|
Review follow-up, pushed in 7a1469e and 9eb4c6f:
All four review threads are answered and resolved. |
|
Correction to my follow-up above, pushed in 96582e7:
|
There was a problem hiding this comment.
I re-reviewed the latest push and found no bugs; both points from my earlier pass (the 30 s per-test timeouts and isPaused reporting true after a refused pause()) are addressed in the current code. Since this changes socket read-interest inside the WebSocket client's close path, a human look at the lifecycle reasoning is still worthwhile.
What was reviewed:
- The
resume()call insend_close_with_body(src/http_jsc/websocket_client.rs:1109): it can synchronously close the socket ifus_poll_changefails, but both callers (close()andhandle_datavia the Close-frame echo) hold aRefPtrguard, anddispatch_abrupt_close/dispatch_closetakeoutgoing_websocketonce, so a second dispatch is a no-op. - The new
pause()early return and the C++m_pausedrevert: the kind-Nonecase still latches for a pre-open pause, and the deferred-Close test assertspause()returnsfalsewithisPaused === false. - The fd-leak test: a leaked socket that reuses the baseline
readdirSyncdirfd number is still reported (the next dirfd lands on a new number), so the check cannot pass vacuously; theheldconnection keeps lazily-created fds in the baseline. - One small thing I did not flag as a bug: the
wspackage wrapper (src/js/thirdparty/ws.js:721) sets its own#paused = truebefore calling nativepause(), so itsisPausedwill still readtruein the refused-during-pending-close case while the globalWebSocketreadsfalse.
Extended reasoning...
Overview
The PR changes two native files and one test file. In src/http_jsc/websocket_client.rs, send_close_with_body now calls self.resume() after the pending-close early return and before the has_tcp() check, and pause() returns false without touching the socket when a Close frame is queued behind unsent data. In src/jsc/bindings/webcore/WebSocket.cpp, WebSocket::pause() reverts m_paused to false when a connected client (kind != None) refuses. test/js/web/websocket/websocket-pause.test.ts gains a raw-TLS origin test that awaits the origin socket's close, and a Linux/macOS-only fd-leak suite that diffs /proc/self/fd (or /dev/fd) across the existing MODES matrix plus the deferred-Close orderings.
Security risks
None specific to this change. The code path is the client's own graceful close; it does not parse new untrusted input, change TLS verification, or alter proxy credential handling. The resumed reads after close go to dispatch_data, which drops data once outgoing_websocket is None, matching the pre-existing unpaused behaviour.
Level of scrutiny
Moderate-to-high, because the fix alters poll interest inside a socket lifecycle path with refcounted teardown. I traced the re-entrancy risk of us_socket_resume (it can call us_internal_socket_close_raw when us_poll_change fails): close() and handle_data both hold RefPtr::from_this guards, and the subsequent has_tcp() false branch calls dispatch_abrupt_close, which is idempotent through outgoing_websocket.replace(None). I also checked that the other terminal paths (terminate, finalize, drop_connection_without_callback) close the socket directly and do not wait on a read, so they do not need the same resume. The C++ revert only applies when a client is attached, preserving the pre-open latch (applyPauseToConnectedClient on connect). The tests are in the correct file, use port: 0, poll with a deadline instead of sleeping, and release servers via using/finally.
Other factors
The two inline comments from my prior pass were resolved by the author with follow-up commits that actually change the code (timeouts removed in 7a1469e; m_paused revert in 96582e7), so there is no outstanding objection from me. No third-party reviewer has requested changes. The changed paths are not covered by CODEOWNERS. I could not run the test suite in this environment (no debug build present), so the claim that all nine new tests fail on the unfixed build is the author's, though the fd-diff mechanism is sound by inspection. The minor ws-package isPaused divergence is pre-existing behaviour of that wrapper and not introduced here, so I noted it without treating it as a blocker.
|
Thanks. On the |
Problem
ws.pause(); ws.close()on a clientWebSocketleaks the socket's fd for the life of the process. The close event fires (1000,wasClean). 200 of 200 on 1.4.2, canary and main.EPOLLHUP(packages/bun-usockets/src/loop.c:849).send_close_with_body(src/http_jsc/websocket_client.rs:1100) has already dispatched the close, so JS cannot callresume().wss, through proxies, and with a deferred Close frame.Fix
send_close_with_bodyresumes the socket before it writes the Close frame. JS gets no message after a close.pause()returnsfalseandisPausedstaysfalse.test/js/web/websocket/websocket-pause.test.ts(9 new tests, all fail on 1.4.3-canary). Alsotest/js/web/websocket/andtest/js/first_party/ws. Self-reviewed: 3 concerns raised, 1 addressed, 2 rejected (Notes).Background
pause()callsus_socket_pause. It removes the readable interest from the poll and setsis_paused.EPOLLHUP, the read returns 0, and the dispatcher closes the fd. The TLS client waits for the peer's FIN.resume()and read the tail. OnEPOLLHUPit also removes the fd from epoll.Notes
History. #40566 added
pause(),resume()andisPausedin 1.4.2. The leak exists since then.Deferred Close frame. With unsent data queued, the Close frame queues behind it and the close is dispatched when it drains. Both orders leak on 1.4.3-canary:
pause(); close()andclose(); pause(). In the second order C++ still holds the native client, sopause()reached the socket. The same leak occurs when a message handler callspause()and the peer's Close is in the same read: the parse loop reaches the Close frame, echoes it and shuts the socket down while it is paused.Trace of one
pause(); close()on a debug build of main (BUN_DEBUG_WebSocketClient=1):Sending close with code 1000,clearData, the close event, and nothing more. Withoutpause()the same run continues withonClose(handle_close), which releases the socket's ref on the client. With the fix the paused run logsonClosetoo.Results of the fd tests (4 connections per test, in the test process; the value is the set of fds that are open afterwards and were not open before):
pause(), thenclose()(ws)pause()in the message handler, the peer's Close in the same readpause(), thenclose()with the Close frame behind unsent dataclose(), thenpause()with the Close frame behind unsent datapause()returnstrue, and the fd leakspause()returnsfalse, noneOn TLS the count is 8 because the peer's socket stays open as well: the client never answers the peer's FIN.
Why the servers are in the test process. With the origin in another process, plain
wsdid not leak in my runs on Linux (0 of 50). The origin answers the Close frame before the client polls again. Data that arrives aftershutdown(SHUT_RD)makes Linux reset the connection (TcpExtTCPAbortOnDatagoes up by 1 per connection), and the dispatcher closes a socket on an error event whether it is paused or not. In one process the client handles its ownEPOLLHUPbefore the origin can answer, which is the case from the report. A proxy in between gives the same order.Why not uSockets. The only uSockets call on the plain path is
us_socket_shutdown_read. A rule there does not reachwssor the tunnel, where the client makes no call at all. It also changesBun.Socket#shutdown(true)on a paused socket. That owner is still attached and keeps its ownIS_PAUSEDflag (src/runtime/socket/socket_body.rs), so the two flags diverge. The dispatcher defers a paused socket that is already shut down on purpose (see the comment aboveeof_deferrableinloop.c). #42352 is the sibling case: it resumes insideus_internal_ssl_closeforBun.listen({ tls }), because that wait is inside uSockets. Here the wait is in the WebSocket client.Other cases checked by hand, all leak on 1.4.3-canary and are clean with the fix:
close(1001, "reason"), an 8 MB send backlog beforeclose()(the server still receives every message), the peer closes first and the application callsclose()later, plainwsthrough anhttp://proxy.Windows. The fd tests read
/proc/self/fdor/dev/fd, so those 8 tests skip on Windows. Thewsstest observes the origin's socket and runs everywhere. On windows-x64 it times out on 1.4.3-canary and passes on a debug build of this branch.Related PRs. #42976 documents that a paused client answers no ping and does not see the peer's close until
resume(). #42978 makesisPausedreadfalseon a socket with no connection. This PR changes no docs and does not touch those cases. It only clears the flag when a connected client refusespause()(WebSocket::pauseinWebSocket.cpp), so that apause()that returnsfalseleavesisPausedfalsein the new case too. #42352 is the sibling fix forBun.listen({ tls })inside uSockets.Self-review.
close()andterminate()work while paused, and no test coversterminate(). The docs changes are no longer part of this PR.pause().ws.terminate()onwss://through a CONNECT proxy never closes the proxy socket (20 of 20, the server keeps 20pendingWebSockets). Awsspeer that answers the Close frame and keeps TCP open pins the fd (8 of 8 after 5 s). Both reproduce on 1.4.3-canary without this change and need their own fix (a close timeout, and a close of the tunnel's socket).Review follow-up. The fd tests first ran a fixture in a subprocess with a 30 s per-test timeout. They now run in the test process, compare the set of open fds, and need no timeout (under 1 s each on a debug build). Two multi-line comments in
src/are one line each now.Suites.
test/js/web/websocket/andtest/js/first_party/wson a debug build: 392 pass, 8 skip, 4 fail. The 4 failures are not from this change.websocket.test.js"should connect over https" and "should send and receive messages" need the public internet. "should connect many times over https" exceeds 5 s on a debug build here, and fails the same way on a debug build of main.websocket-proxy-close-reentrancy.test.tstimed out once in the full run and passes alone (3 of 3).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/web/websocket/websocket-pause.test.ts