usockets: report a peer reset behind unread data as ECONNRESET, not 'end'

Merged
RO
3mo ago
+125-18

Problem

  • A tls.createServer socket whose peer resets the connection (RST) while this side still has unread data emits only 'end'. Node emits 'error' with code: 'ECONNRESET' (read ECONNRESET) and never emits 'end'. With allowHalfOpen: true the 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 net server socket in the same situation emits 'end' first. With the default allowHalfOpen: false the 'end' destroys the socket and the ECONNRESET is lost too. With allowHalfOpen: true it 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 (always allow_half_open at this layer) that dispatches on_end, and only then does loop.c:929 close with the socket error. A TLS socket's on_end (us_internal_ssl_on_end) closes the socket itself with the clean code 0, so the error close at loop.c:929 finds a closed socket and the error is gone.
    • loop.c:825 (read loop): a recv() 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, so recv() returns the data and then ECONNRESET. 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. Only recv() == 0 sets the new read_fin flag.
  • 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 reports SO_ERROR.
  • A FIN that recv() did return on an error event keeps today's behavior: on_end, then the error close (test-net-error-twice and the teardownNoise logic in net.ts depend 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 with read 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 nonzero fflags) 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 into read ECONNRESET (SocketEmitEndNT in src/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: false now also gives error ECONNRESET, close for 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=true in all variants.
  • No regressions found: the 1026 vendored test-net-*, test-tls-*, test-http-*, test-https-* and test-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 nine test-net-half-open-peer-reset-* fixtures and test/js/valkey/reliability/connection-failures.test.ts were 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, localhost resolution, debug-build timing).

Background

  • usockets event dispatch: us_internal_dispatch_ready_poll receives three things per socket event: events (readable, writable), error (EPOLLERR, or kqueue EV_EOF with a socket error in fflags) and an eof hint (EPOLLHUP, which Linux sets once both directions are down, or kqueue EV_EOF). The read loop calls recv() until it returns 0 (a FIN), an error, or EAGAIN. After the loop, the eof block dispatches on_end and handles half-open, and the error block closes the socket with SO_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 with ECONNRESET. The poll reports readable, error and hangup at the same time. Windows discards the queue, so recv() 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_open in usockets. net.ts implements the JS allowHalfOpen option 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 dispatches on_end and then falls through to the error close.
  • TLS on_end: for node TLS sockets, us_internal_ssl_on_end does not dispatch an end event. It closes the socket with code 0 and net.ts synthesizes 'end' from that clean close. That is why the lost error was visible on TLS sockets first: for plain sockets on_end only pushes EOF, and the error close that followed still reached JS in the allowHalfOpen: true case.
  • Close codes: on_close receives either a usockets close code (0 clean, 1 reset, 2 fast shutdown) or, for closes driven by the event loop, an errno. net.ts destroys the socket with read ECONNRESET when the close carries an errno and an 'error' listener exists.