Skip to content

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

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/f29f42f4/windows-close-error-code
Aug 19, 2026
Merged

socket: report a peer reset on Windows as close(socket, ECONNRESET) instead of a code-less error#39615
Jarred-Sumner merged 2 commits into
mainfrom
farm/f29f42f4/windows-close-error-code

Conversation

@robobun

@robobun robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

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 usockets: report a peer reset on a paused socket on kqueue #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.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:10 AM PT - Aug 19th, 2026

@robobun, your commit 6bc25bc is still building in Build #101104, but has 1 failures so far (All Failures):

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on a Windows x64 machine. With the released binary, close(socket, error) after a peer reset carries { code: undefined, errno: -10054, syscall: "read" } for tcp and tls. The fix is Windows only, so on Linux the tests pass in both states.

Proof on a Windows x64 debug build of the current branch (6bc25bc, rebased on main after #39610 landed): the three new tests in test/js/bun/net/socket.test.ts and the two paused-reset tests from #39610, which no longer skip the code check on Windows, all fail without the src and packages diff (code: undefined) and pass with it.

CI on the pre-rebase sha aad7e50 (build 101072) passed on every Windows lane. Its two red lanes were the darwin paused-reset test that #39610 has since fixed and the asan worker-terminate-funnels failure that #39606 has since fixed. Both are in the rebased base now. Waiting for CI on 6bc25bc.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a6ce4c20-a6ef-40ce-84c7-52a825227832

📥 Commits

Reviewing files that changed from the base of the PR and between aad7e50 and 6bc25bc.

📒 Files selected for processing (4)
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/loop.c
  • packages/bun-usockets/src/socket.c
  • test/js/bun/net/socket.test.ts

Included review availability: Your plan provides up to 5 included reviews per hour; 3 remain after this review.


Walkthrough

This change standardizes socket error fallbacks across platforms, converts close codes into normalized read errors, and adds TCP and TLS regression tests for peer resets.

Changes

Socket error normalization

Layer / File(s) Summary
Platform error codes and fallbacks
packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/eventing/libuv.c, packages/bun-usockets/src/loop.c, packages/bun-usockets/src/socket.c
Adds LIBUS_ECONNRESET, uses LIBUS_ERR when socket-error queries fail, and replaces platform-specific reset fallbacks.
Close error mapping
src/runtime/socket/socket_body.rs
Converts uSockets close codes into system read errors and normalizes Windows reset and abort errors to ECONNRESET.
Reset regression coverage
test/js/bun/net/socket.test.ts
Tests TCP and TLS peer resets for accepted and connected sockets. The tests verify read ECONNRESET errors from close().

Possibly related PRs

  • oven-sh/bun#39610: Overlaps in socket reset handling and TCP/TLS regression tests.
  • oven-sh/bun#39579: Overlaps in socket error normalization across uSockets and runtime propagation.
  • oven-sh/bun#39542: Overlaps in socket error normalization and propagation through socket_body.rs.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: reporting Windows peer resets as ECONNRESET.
Description check ✅ Passed The description explains the problem, fix, scope, background, and verification results in sufficient detail.

Comment @coderabbitai help to get the list of available commands.

Comment thread test/js/bun/net/socket.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the previous nit (wiring peer.exited to ready.reject) is addressed in e378ee3.

What was reviewed:

  • Verified the C macro changes are byte-identical on POSIX (LIBUS_ECONNRESETECONNRESET, LIBUS_ERRerrno) and only shift Windows to WSA numbering.
  • Checked us_socket_get_error (libuv.c) — Winsock getsockopt failure reports via WSAGetLastError(), not errno, so the swap is correct; all other callers only compare != 0.
  • Confirmed read_error_from_close_code mirrors the existing connect-error mapping (SystemErrno::init(u32) at socket_body.rs:1163) and sys::Error::new(SystemErrno, Tag) exists; the POSIX arm is identical to the old inline call.
Extended reasoning...

Overview

Six files touched: a new LIBUS_ECONNRESET macro in internal.h, three C sites in usockets swapped from bare ECONNRESET/errno to the platform-numbered LIBUS_ECONNRESET/LIBUS_ERR, a new read_error_from_close_code helper in socket_body.rs that maps WSA codes to SystemErrno on Windows only, and three regression tests in socket.test.ts.

Security risks

None. This only changes which errno constant a peer-initiated close carries into JS. No parsing of untrusted input, no allocation, no auth/crypto.

Level of scrutiny

The diff sits in the socket event-loop hot path, so I checked each change against both platforms. On POSIX every C change is a macro that expands to the exact token it replaced (LIBUS_ERRerrno, LIBUS_ECONNRESETECONNRESET), and the Rust POSIX arm is the previous body verbatim — so POSIX behaviour is provably unchanged. On Windows: us_socket_resume's failure branch is unreachable (libuv us_poll_change always returns 0), the SEMI_SOCKET connect fallback already had the #ifdef for WSAECONNRESET, and the remaining two sites (us_socket_get_error failure return and the poll-error close fallback) fix real numbering mismatches the PR describes. The Rust Windows arm follows the same SystemErrno::init(u32) pattern already used for connect errors at line 1163, and folds WSAECONNABORTED into ECONNRESET per libuv's uv__process_tcp_read_req.

Other factors

The tests are well-constructed: the child-process kill guarantees an RST with nothing queued ahead (avoiding the TLS close_notify clean-end problem the comment explains), every failure event including peer.exited is wired to reject the awaited promise, resources use using/await using, and describe.concurrent matches the file's existing convention. The author verified fail-without/pass-with on a Windows debug build and 25/25 flake-free repeats on Linux; CI on aad7e50 was green on all Windows lanes with the two red lanes attributed to unrelated known issues. The PR description enumerates every other consumer of the close code and confirms none read it as an error. My earlier review's only nit was addressed and the thread is resolved.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

…ies ECONNRESET

On Windows usockets closes a reset socket with the WSA code (WSAECONNRESET,
10054) from recv() or SO_ERROR. on_close stored that value as a SystemErrno
discriminant, so the error reached JS without a code. Map it through the
WSA table the way the connect error path does. WSAECONNABORTED is reported
as ECONNRESET like libuv does on its read path, and a code the table does
not name is reported as ECONNRESET as well.

The fallbacks usockets uses when SO_ERROR reports nothing used the CRT's
ECONNRESET (108 on Windows), which reads as ESHUTDOWN in the SystemErrno
numbering. They now use LIBUS_ECONNRESET, which is WSAECONNRESET on Windows,
and us_socket_get_error reports a failed getsockopt with LIBUS_ERR.
@robobun
robobun force-pushed the farm/f29f42f4/windows-close-error-code branch from e378ee3 to 6bc25bc Compare August 19, 2026 06:57
@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main (6bc25bc). The conflict was in test/js/bun/net/socket.test.ts: #39610 appended its paused-reset tests at the same place. Both blocks are kept. The rebase also removes the Windows guard from the #39610 tests, since this change is what it was waiting for, so those two tests now check code: "ECONNRESET" on Windows as well. The C and Rust hunks merged without changes. All five reset tests pass on a Linux debug build of the rebased branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants