socket: report a peer reset on Windows as close(socket, ECONNRESET) instead of a code-less error

Merged
RO
3mo ago
+176-23

Problem

  • On Windows, close(socket, error) after a peer reset gets an error with no code (errno: -10054, message Unknown Error, read). Linux and macOS report ECONNRESET.
  • Cause: usockets reports the close error in the platform's numbering, a WSA code on Windows (WSAECONNRESET = 10054). on_close in src/runtime/socket/socket_body.rs stores it unmapped with sys::Error::from_code_int, which on Windows holds SystemErrno discriminants. 10054 is not one.
  • The poll-error fallback in loop.c is the CRT's ECONNRESET, 108 on Windows. Discriminant 108 is ESHUTDOWN.

Fix

  • On Windows, on_close maps the code through the WSA table, as the connect error path does. WSAECONNABORTED becomes ECONNRESET, as in libuv's read path (uv__process_tcp_read_req), so the code matches node. An unknown code becomes ECONNRESET too: the connection is gone either way.
  • The fallbacks in loop.c and us_socket_resume use the new LIBUS_ECONNRESET (WSAECONNRESET on Windows), and the libuv us_socket_get_error returns LIBUS_ERR when getsockopt fails. All close codes now share one numbering per platform.
  • POSIX does not change: the code is already an errno there, and the macro expands to the same constant.
  • Verified: 3 new tests in test/js/bun/net/socket.test.ts, plus the two paused-reset tests from #39610, which now check the code on Windows too. A Windows x64 debug build fails them without the src and packages diff and passes with it. Linux passes both ways. The net and tls reset suites pass on both platforms (notes).

Background

  • usockets closes a socket from the event loop when recv() fails or the poll reports an error, and the close code is then the error (LIBUS_ERR, SO_ERROR, or a fallback). Codes 0 to 2 are closes that Bun started. NewSocket::on_close passes a larger code to the JS close handler as error.
  • On Windows, SystemErrno uses Linux numbering. SystemErrno::init maps a Win32 or WSA code onto it, and sys::Error stores the mapped value.
Notes

Test design: the peer is a child process killed while it has unread data, so the kernel sends an RST with nothing queued ahead of it. An in-process TLS terminate() sends a close_notify first, which a reading POSIX peer consumes as a clean end, so it cannot stand in for the reset in the tls case. The third test resets a plain tcp connection from the server side in-process and checks the Bun.connect socket, which shares on_close.

Error shape on Windows x64 (Bun.listen socket, client terminate()), tcp and tls give the same result:

  • unfixed release build: { code: undefined, errno: -10054, syscall: "read", message: "Unknown Error, read" }
  • this branch: { code: "ECONNRESET", errno: -4077, syscall: "read", message: "ECONNRESET: connection reset by peer, read" }
  • node v26.3.0 on the same machine, net server socket: { code: 'ECONNRESET', errno: -4077, syscall: 'read' }

The ESHUTDOWN flavor: the poll-error close in loop.c is reached on Windows when libuv reports an error status for the poll. Windows does not reliably latch a received RST in SO_ERROR (see us_internal_libuv_peer_reset_probe), so the fallback is taken there. #37104 observed it as error=ESHUTDOWN in its test matrix. From JS, a reset on a reading or paused socket goes through the recv() close on Windows (the paused probe in poll_cb adds READABLE, and Windows discards the receive queue on a reset), so the new tests cover the recv() flavor and the fallback is fixed by inspection. A reset that arrives after the accepted socket consumed the peer's FIN (half open, polling nothing) is not reported at all on Windows, with or without this change. That is a separate defect and is not touched here.

src/js/node/net.ts keeps its code === undefined branches. With this change it takes the code === "ECONNRESET" branch on Windows and reports errno: -4077 like node, instead of -10054.

Other consumers of the close code were checked: the HTTP client, WebSocket client, Postgres, MySQL, Valkey, IPC and uWS ignore the value (uWS WebSocket uses it as a reason length for its own closes). NewSocket::on_close is the only consumer that reads it as an error.

Related PRs: #39579 adds LIBUS_ECONNABORTED and LIBUS_ECONNREFUSED to the same block of internal.h for the connect path, and LIBUS_ECONNRESET follows that shape. Whichever lands second has a small merge in internal.h. #39610 landed first. Its paused-reset tests in socket.test.ts checked the code on POSIX only because of this bug, and the rebase removes that guard, so they are part of the proof now (they observed code: undefined on both Windows lanes in that PR's CI).

Suites run on the Windows x64 debug build: test/js/bun/net/socket.test.ts (82 pass, 9 skip), test/js/node/net/node-net.test.ts (78 pass, 1 fail: "should allow reconnecting after end()" is a 3 ms timer race with no reset in it, and it passes in 2 of 4 runs on this build), the 4 reset tests in test/js/node/tls/node-tls-server.test.ts, and the 17 files in test/js/node/test/parallel whose names match reset, econnreset or error-twice. On the Linux debug build: socket.test.ts (the same 9 failures as the released binary in this container: localhost resolution and external network), the node-tls-server reset tests, test-net-error-twice, test-net-server-reset, test-net-socket-reset-send, test-net-socket-reset-twice, test-tls-econnreset, test-tls-wrap-econnreset, test-tls-wrap-econnreset-socket and test-http-conn-reset. The new tests passed 25 of 25 repeated runs on Linux with the released binary.