socket: report a peer reset on Windows as close(socket, ECONNRESET) instead of a code-less error
Problem
- On Windows,
close(socket, error)after a peer reset gets an error with nocode(errno: -10054, messageUnknown Error, read). Linux and macOS reportECONNRESET. - Cause: usockets reports the close error in the platform's numbering, a WSA code on Windows (
WSAECONNRESET= 10054).on_closeinsrc/runtime/socket/socket_body.rsstores it unmapped withsys::Error::from_code_int, which on Windows holdsSystemErrnodiscriminants. 10054 is not one. - The poll-error fallback in
loop.cis the CRT'sECONNRESET, 108 on Windows. Discriminant 108 isESHUTDOWN.
Fix
- On Windows,
on_closemaps the code through the WSA table, as the connect error path does.WSAECONNABORTEDbecomesECONNRESET, as in libuv's read path (uv__process_tcp_read_req), so the code matches node. An unknown code becomesECONNRESETtoo: the connection is gone either way. - The fallbacks in
loop.candus_socket_resumeuse the newLIBUS_ECONNRESET(WSAECONNRESETon Windows), and the libuvus_socket_get_errorreturnsLIBUS_ERRwhengetsockoptfails. 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 thesrcandpackagesdiff 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_closepasses a larger code to the JSclosehandler aserror. - On Windows,
SystemErrnouses Linux numbering.SystemErrno::initmaps a Win32 or WSA code onto it, andsys::Errorstores 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,
netserver 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.