usockets: report a peer reset behind unread data as ECONNRESET, not 'end'
Merged
RO
3mo ago+125+125+012345678901234567890123456789-18-18-01234567890123456789
Problem
- A
tls.createServersocket whose peer resets the connection (RST) while this side still has unread data emits only'end'. Node emits'error'withcode: 'ECONNRESET'(read ECONNRESET) and never emits'end'. WithallowHalfOpen: truethe Bun socket then stays open forever. Found while working on #39548, whose TLS stub had to move into a Node child process because of this. - A plain
netserver socket in the same situation emits'end'first. With the defaultallowHalfOpen: falsethe'end'destroys the socket and theECONNRESETis lost too. WithallowHalfOpen: trueit emits'end', then'error'. - Cause, in
us_internal_dispatch_ready_poll(packages/bun-usockets/src/loop.c):loop.c:885(eof block): an event that carries the error flag (EPOLLERR next to EPOLLHUP) still takes the orderly end path first. For node sockets (alwaysallow_half_openat this layer) that dispatcheson_end, and only then doesloop.c:929close with the socket error. A TLS socket'son_end(us_internal_ssl_on_end) closes the socket itself with the clean code 0, so the error close atloop.c:929finds a closed socket and the error is gone.loop.c:825(read loop): arecv()error that followed data was folded into an end of stream (if (eof && read_any) break;) and went down the same end path. On Linux the kernel keeps the receive queue after a reset, sorecv()returns the data and thenECONNRESET. This is the normal shape of a reset behind unread data.
Fix
- The read loop closes with the
recv()errno whether or not it delivered data first. Onlyrecv() == 0sets the newread_finflag. - An error event whose read loop did not read a FIN (
eof && error && !read_fin) skips the end path and goes straight to the existing error close, which reportsSO_ERROR. - A FIN that
recv()did return on an error event keeps today's behavior:on_end, then the error close (test-net-error-twiceand theteardownNoiselogic innet.tsdepend on that order). - Why this is correct: it is what libuv reports to Node.
read()returns the data and then the error, Node destroys the socket withread ECONNRESET, and'end'needs a real EOF. A FIN is the only thing that means end of stream. EPOLLHUP (and kqueue's EV_EOF with a nonzerofflags) is also set by a reset, so it cannot stand in for one once the error flag is present. The JS layer already turns a close that carries an errno intoread ECONNRESET(SocketEmitEndNTinsrc/js/node/net.ts), so no JS change is needed. - Verified with
test/js/node/tls/node-tls-server.test.ts, "server socket whose peer resets the connection behind unread data": 4 tests (tls and net, reset while paused and reset drained behind data). All 4 fail on main (they observe["end"]) and pass with this change, with both the release binary and a debug build of main. - The same scenario with
allowHalfOpen: falsenow also giveserror ECONNRESET, closefor tls and net (script run, not a test: it is the same native path). - Node v26.3.0 running the same server logic gives
error ECONNRESET (read ECONNRESET),close hadError=truein all variants. - No regressions found: the 1026 vendored
test-net-*,test-tls-*,test-http-*,test-https-*andtest-http2-*files,test/js/node/tls,test/js/node/net,test/js/node/http,test/js/bun/net,test/js/bun/http,test/js/web/fetch, the ninetest-net-half-open-peer-reset-*fixtures andtest/js/valkey/reliability/connection-failures.test.tswere run on a debug build. Every remaining failure also fails on a debug build of main or on the release binary in this container (external network,localhostresolution, debug-build timing).
Background
- usockets event dispatch:
us_internal_dispatch_ready_pollreceives three things per socket event:events(readable, writable),error(EPOLLERR, or kqueue EV_EOF with a socket error infflags) and aneofhint (EPOLLHUP, which Linux sets once both directions are down, or kqueue EV_EOF). The read loop callsrecv()until it returns 0 (a FIN), an error, or EAGAIN. After the loop, the eof block dispatcheson_endand handles half-open, and the error block closes the socket withSO_ERROR. - Reset behind unread data: when a peer sends data and then resets, Linux and macOS keep the data that is already in the receive queue.
recv()returns it and then fails withECONNRESET. The poll reports readable, error and hangup at the same time. Windows discards the queue, sorecv()fails at once there. The test only asserts that data was delivered on POSIX. - Half-open at this layer: every node socket, and every uWS HTTP socket, is
allow_half_openin usockets.net.tsimplements the JSallowHalfOpenoption on top. That is why a reset on a node socket went through the half-open branch of the eof block, which is the branch that dispatcheson_endand then falls through to the error close. - TLS
on_end: for node TLS sockets,us_internal_ssl_on_enddoes not dispatch an end event. It closes the socket with code 0 andnet.tssynthesizes'end'from that clean close. That is why the lost error was visible on TLS sockets first: for plain socketson_endonly pushes EOF, and the error close that followed still reached JS in theallowHalfOpen: truecase. - Close codes:
on_closereceives either a usockets close code (0 clean, 1 reset, 2 fast shutdown) or, for closes driven by the event loop, an errno.net.tsdestroys the socket withread ECONNRESETwhen the close carries an errno and an'error'listener exists.