ws: close server sockets through the drain path and keep them CLOSING until 'close' is emitted
Rewritten after the first review: no immediates, the close goes through the drain path.
Problem
- A server socket of the built-in
wsshim is CLOSED whenclose()returns, and a'close'listener added after it never fires. npm ws is CLOSING and emits'close'later, with aBufferreason. Cause: uws runs the close callback insideServerWebSocket.close()(packages/bun-uws/src/WebSocket.h:270) and#close(ws.js:1056on main) emitted right there. close()with messages still queued fordrainlost them.send()after close dropped its callback,bufferedAmountignoredgetBufferedAmount(), and#drainresent messages uws had accepted.- uws never sent the FIN for a
close()issued inside thedrainhandler. The connection only went away through theend()timer, 16 s by default. PlainBun.servereproduces it.
Fix
close()records the pending close and runs the drain step: queued messages go out first, the native close follows oncegetBufferedAmount()is 0. The close callback stays in CLOSING, fails the callbacks of undelivered messages, and emits'close'on the next tick with aBufferreason.send()on a closing or closed socket calls back with the "not open" error and counts the bytes.bufferedAmountadds what uws has buffered. A native0is a drop only for a non-empty payload.onWritableshuts the socket down after the drain handler whenend()was called in it, asonDataalready does (WebSocketContext.h:339). Bun runs the handler corked, soend()leaves the FIN to the caller.- Verified:
test/js/first_party/ws/ws.test.ts(block "server socket close lifecycle") andtest/js/bun/websocket/websocket-server-backpressure-buffer.test.ts. All 9 new tests fail on the released build. More in the notes.
Background
"ws"always resolves to the shim. Its server socket is a JS state machine over theBun.servewebsocket callbacks installed bynode:http(_http_server.ts:648).- Native
send()returns the byte count,-1if uws buffered the message,0if it dropped it (after 16 MiB). The shim queues dropped messages and retries them fromdrain. - After sending a Close frame uws ignores incoming data, so the peer's reply code is not observable.
'close'carries the code passed toclose(), which a compliant peer echoes anyway.
Notes
Reference behavior was taken from node v26 with ws 8.18.3 (test/node_modules/ws) using a raw RFC 6455 client, so both implementations saw identical bytes. The shim now matches on: readyState after close() and terminate(), the send and ping callbacks after close, 'close' args (4000, Buffer("bye")) for a local close with an echoing peer, (1006, Buffer(0)) after terminate(), (4444, Buffer("other")) for a peer close, bufferedAmount === 4 after a late send("late"), and send("", cb) calling back with one empty frame on the wire.
Known remaining differences: the close code after a local close() is the local one (native limitation above). The binary frame shape for binaryType = "arraybuffer" was fixed on main by #39808 (now in the base of this branch), the text frame shape is #36061. The deferred 'close' relies on request handlers running with the event loop entered (#39826, in the base of this branch): before that, a nextTick queued inside close() ran before close() returned when called from the 'connection' handler. The test "close() inside the 'connection' handler" pins that case.
Why the close waits for getBufferedAmount() === 0 and not only for the shim queue: uws end() sends the Close frame with send(), and when the socket is over its backpressure limit that returns DROPPED, which end() treats as sent and shuts the socket down at once. The released build delivers 2.5 MiB of 26 MiB in the new drain test for that reason. That is a separate uws issue, reported separately. Waiting for an empty buffer also matches when npm ws emits 'close'.
Probe for the FIN bug with plain Bun.serve: pause a raw client, ws.send() 1 MiB until it returns -1, in drain call ws.close() once getBufferedAmount() is 0, resume the client. Released build: the client gets the Close frame and no FIN for 4 to 16 s (the end() timer). This branch: FIN right after the Close frame. The new test relies on that timer being 16 s at the default idleTimeout, so it times out on the released build instead of passing late.
The backpressure tests are skipped on Windows like the ones already in that file: Winsock's loopback takes the whole payload into the kernel. On Linux and macOS the kernel takes under 8 MiB from a paused reader (the existing 8 MiB test depends on the same), so the queue test sends 26 x 1 MiB to get past the 16 MiB limit. The two blocks take about 1.5 s on a debug ASAN build and were stable over 6 runs.
#drain callbacks moved from queueMicrotask to process.nextTick, which is what send() uses for the direct path.
From the second review round (1607583): payloadByteLength sizes a payload that is neither a string nor buffer-like as the text frame of its string form, which is what the native send() sends, so a drop of such a message is not mistaken for a delivered empty frame. The close callback subtracts the entries it discards instead of zeroing the counter, so bytes counted by a send() during CLOSING stay in bufferedAmount as in npm ws. The test "terminate() with messages still queued fails their callbacks" pins that and the error callbacks of undelivered messages.
Suites run on the debug build: test/js/first_party/ws/ (4 files), node-http-with-ws.test.ts, the ws regression tests (012040, 03844, 26358, 29684, 32734, 3613), test/js/bun/websocket/ (the 8 failures in websocket-server.test.ts are the debug-build fixture timeouts that #39370 addresses, the file passes on the released build here too), test/js/web/websocket/ (2 failures need the public internet). ws-proxy.test.ts needs HTTP_PROXY unset in this container, on the released build as well.
Overlap with #39843: that PR fixes both uws findings from this one in end() itself (Close frame past the backpressure limit, FIN sent by end() after uncork), which covers the drain handler case too. The onWritable hunk here then becomes redundant and can be dropped when #39843 lands first, or #39843 can remove it on rebase. Both can coexist at runtime: shutdown() on an already shut down socket is a no-op in usockets (raw and SSL). The two PRs also both add tests to websocket-server-backpressure-buffer.test.ts, so whichever lands second needs a small rebase.
Rebased onto main after #39642 and #39808 landed in ws.js. The only conflicts were adjacent additions (the controlPayload helper next to payloadByteLength/wasDropped, and the EventEmitter/isWindows imports in the test file), kept both sides.
no test proof · iteration 5 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/first_party/ws/ws.test.ts, test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts