net(windows): surface the real WSA error from a failed connect() - #36786
Conversation
On Windows the node:net connect-error path was reporting ECONNREFUSED (or ENOENT for a path connect) for every failure because the raw WSA code from Winsock was never mapped to the SystemErrno discriminant the errno whitelist compares against: - The async path (loop.c's SEMI_SOCKET SO_ERROR read and context.c's recv probe) passes raw WSA codes (WSAECONNRESET = 10054, WSAEADDRINUSE = 10048) into handle_connect_error, whose whitelist is keyed on POSIX-numbered SystemErrno values (104, 98). Nothing matched, so every async connect failure fell through to ECONNREFUSED. - The synchronous path (connect_finish after do_connect() errs) read the CRT's thread-local _errno(), which Winsock never sets, so it always saw 0 and fell through to the default. - bsd.c's AF_UNIX connect path discarded bsd_do_connect_raw's return value and let closesocket() overwrite WSAGetLastError. Fix: preserve the WSA code across closesocket in bsd.c; read WSAGetLastError() in connect_finish on Windows; normalize any value >= WSABASEERR through SystemErrno::init before the whitelist in handle_connect_error; and remap every whitelisted code (not just ENOENT/ECONNREFUSED) to its UV_* value so err.errno matches Node. Winsock AF_UNIX returns WSAECONNREFUSED for any path with no listening socket, existing or not, where Node (via libuv's uv_pipe_connect, which uses CreateFile) distinguishes ENOENT. connect_finish now refines ECONNREFUSED to ENOENT on the failure path when the target path does not exist. Enables upstream parallel/test-net-client-bind-twice, parallel/test-net-server-reset and parallel/test-net-pipe-connect-errors on Windows.
|
Warning Review limit reached
Next review available in: 17 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
Reproduced on windows-x64 with: All three (plus the new |
There was a problem hiding this comment.
No bugs found. This is a well-reasoned Windows-only fix, but it threads error-code semantics through three layers (usockets C, connect_finish, handle_connect_error) and un-quarantines three upstream Node tests, so it's worth a human look before landing.
What was reviewed:
bsd.c: the two newWSASetLastError(rc)calls mirror the existing bind-failure path;rcis the WSA code returned bybsd_do_connect_raw, not a POSIX errno.Listener.rs:bun_sys::windows::WSAGetLastError()already routes throughSystemErrno::init, soeis a discriminant that the existing whitelist below compares against; the ENOENT refinement only runs on the failure path.socket_body.rs: the>= 10000gate is disjoint from both the CRT errno space and theSystemErrnodiscriminant space (max 137 + UV_ tail); every whitelisted code has a correspondingUV_*variant inwindows_errno.rs.
Extended reasoning...
Overview
Fixes Windows net.connect() error reporting: raw WSA codes were reaching a whitelist keyed on POSIX-numbered SystemErrno discriminants, so every connect failure degraded to ECONNREFUSED/ENOENT. Touches packages/bun-usockets/src/bsd.c (re-arm WSA error after closesocket), src/runtime/socket/Listener.rs (connect_finish reads WSAGetLastError on Windows and refines ECONNREFUSED→ENOENT for missing AF_UNIX paths), and src/runtime/socket/socket_body.rs (handle_connect_error normalizes WSA codes ≥ WSABASEERR and remaps every whitelisted code to its UV_* value). Removes three Windows quarantines from expectations.txt and adds three Windows-only tests.
Security risks
None identified. This is error-code mapping on connect-failure paths; no auth, crypto, or untrusted-input parsing. The added bun_sys::exists() check on the AF_UNIX failure path is a benign TOCTOU (worst case: reports ECONNREFUSED instead of ENOENT if the file appears between the failed connect and the check).
Level of scrutiny
Moderate. Every code change is #ifdef _WIN32 / #[cfg(windows)]-gated, so POSIX behavior is untouched. But it changes error semantics across a C→Rust boundary in the socket connect path, un-quarantines three upstream Node tests, and adds a filesystem probe to an error path. Per the repo's cross-platform review guidance, platform-gated FFI changes warrant a human read.
Other factors
- I verified
bun_sys::windows::WSAGetLastError()already maps viaSystemErrno::init(windows/mod.rs:697-703), so theerr as c_intcast inconnect_finishyields the same discriminant space the existing whitelist compares against. - I verified all seven
UV_*variants used in the new match arm exist on the WindowsSystemErrno(windows_errno.rs:36-53). - The
bsd.cchange follows the exact pattern already used at thebind()-failure site in the same function. - robobun confirmed all three previously-failing upstream tests plus the new describe block pass on windows-x64 and still pass on Linux.
- The PR description's root-cause analysis is precise and matches the code.
…normalization comment; loop.c fallback emits WSAECONNRESET on Windows
There was a problem hiding this comment.
No bugs found, but this touches Windows-specific error-code plumbing across the C↔Rust FFI boundary (bsd.c re-arming WSA error, cfg(windows) errno remapping, the exists() heuristic to split ECONNREFUSED→ENOENT), so it's worth a human look.
What was reviewed:
- Sign convention on the new
e_discriminant_to_uvremap:-(-4078)→errno: -4078, matches the old ENOENT/ECONNREFUSED special-cases and extends to the full whitelist. WSASetLastError(rc)afterbsd_close_socketmirrors the existing bind-failure path in the same file;rcis the WSA code returned bybsd_do_connect_raw.bun_sys::exists(path)refinement runs only after connect has already failed with ECONNREFUSED on a unix path — informational error-code selection, not check-then-act.- Removed quarantines have direct new coverage in node-net.test.ts plus the un-quarantined upstream tests.
Extended reasoning...
Overview
Windows-only fix for net.connect() reporting the wrong error code on every failure. Three sites lost or misread the WSA error: bsd.c's connect-failure tail let closesocket() clobber WSAGetLastError; connect_finish read CRT _errno() (which Winsock never sets); and handle_connect_error's whitelist compared POSIX-numbered discriminants against raw WSA codes (10048 vs 98). The fix re-arms WSA error after close, reads WSAGetLastError() on Windows, and normalizes WSA codes ≥10000 through SystemErrno::init before the whitelist. The old two-case UV_* remap is generalized via e_discriminant_to_uv. Three upstream Node test quarantines are removed and three Windows-gated tests added.
Security risks
None. This is error-code presentation on an already-failed connect. The bun_sys::exists call refines ECONNREFUSED→ENOENT after failure for user-facing reporting only; it's not a security check-then-act.
Level of scrutiny
Medium-high. All runtime changes are #[cfg(windows)] / #ifdef _WIN32 gated, so POSIX behavior is unchanged. But it's platform-gated native code across the C↔Rust FFI boundary, and per the repo's cross-platform review guidance that warrants a maintainer look. Two spots a human might want to weigh in on: (1) the hardcoded >= 10000 for WSABASEERR rather than a named constant, and (2) whether the exists() refinement belongs in connect_finish or should live closer to the AF_UNIX connect in bsd.c.
Other factors
The PR is well-documented with a clear repro and mechanism trace. robobun confirmed all three upstream tests plus the new describe block pass on windows-x64 and still pass on Linux. The comment-cop bot flagged long comments on an earlier revision; those threads are marked resolved and the current diff's comments are concise. The loop.c change (WSAECONNRESET fallback) makes the SEMI_SOCKET fallback consistent with the WSA numbering the rest of the Windows path now expects.
…t.test.ts without the websocket ASAN skip #36786 fixed test-net-pipe-connect-errors.js, test-net-client-bind-twice.js and test-net-server-reset.js on Windows by mapping the WSA error to the SystemErrno discriminant. test-net-pingpong.js remains (separate uv_shutdown gap). inspect.test.ts: the no-validate-exceptions.txt entry on its own may be enough on release-asan now that validateExceptionChecks is off; the earlier 270s hang on the first websocket case was with that flag set. Letting CI decide; will restore the describe.skipIf(isASAN) if it still hangs. expectations.txt is now 3 entries.
Empirically re-derived which `test/expectations.txt` entries are still needed by removing all 29 and running the full CI matrix ([build 87834](https://buildkite.com/bun/bun/builds/87834)). ## Result: 29 entries → 3 ### Kept (3): still fail on the named lane | entry | lane | observed failure (build 87834) | |---|---|---| | `test/bundler/native-plugin.test.ts` | WINDOWS | MSB8020: ClangCL build tools not found (agent image gap) | | `test/js/node/test/parallel/test-net-pingpong.js` | WINDOWS | named-pipe half-close: count 1000 !== 1001 | | `test/js/node/test/sequential/test-net-listen-shared-ports.js` | LINUX | SO_REUSEPORT shared-listener semantics; passes on macOS/Windows | ### Deleted (6): vendored Node tests that fail deterministically on every lane These can never pass as vendored; removing the files instead of re-quarantining. | file | reason | |---|---| | `test-stream-wrap.js`, `test-stream-wrap-drain.js`, `test-stream-wrap-encoding.js` | require `internal/js_stream_socket` which Bun does not implement | | `test-net-connect-keepalive.js`, `test-net-server-keepalive.js` | assert `_handle.setKeepAlive` receives seconds (libuv convention); Bun's `_handle` is Bun.Socket (ms). End-to-end TCP_KEEPIDLE coverage is in `test/js/bun/net/socket.test.ts` | | `test-set-http-max-http-headers.js` | spawns `test-http-max-http-headers.js` which is not vendored | ### Moved to `no-validate-exceptions.txt` (7): fail only via unchecked-exception assertions These now **run** on ASAN with `validateExceptionChecks` off, instead of being removed from the run entirely. | file | unchecked exception scope | |---|---| | `test/integration/next-pages/test/dev-server-ssr-100.test.ts` | `JSOrderedHashTable::getImpl` → `executeBoundCall` | | `test/integration/next-pages/test/dev-server.test.ts` | same | | `test/integration/next-pages/test/next-build.test.ts` | same | | `test/js/third_party/next-auth/next-auth.test.ts` | same | | `test/napi/napi.test.ts` | `Process_functionDlopen` (BunProcess.cpp:397) | | `test/cli/run/require-cache.test.ts` | `NapiClass::finishCreation` (NapiClass.cpp:120) | | `test/cli/inspect/inspect.test.ts` | `getOwnNonIndexPropertyNames` → `JSObjectInlines::get` (inspector Runtime.evaluate) | Also bumped `esm-fixture-leak-small.mjs` ASAN threshold 400→500 MB (build 87834 measured 407 MB; ASAN quarantine overhead) so `require-cache.test.ts` passes end to end on ASAN. ### Removed (13): now pass on their named lane | entry | was scoped to | now passes on | |---|---|---| | `test/js/node/test/parallel/test-repl-close.js` | WINDOWS-AARCH64 | windows 11 aarch64 | | `test/js/node/test/parallel/test-tls-connect-memleak.js` | LINUX-X64-MUSL | alpine 3.23 x64 + aarch64 | | `test/js/bun/spawn/spawn-maxbuf.test.ts` | (all) | every lane (also fixed for debug in #36782) | | `test/js/bun/spawn/spawn.test.ts` | ASAN | every lane (heap-use-after-free fixed in #36783) | | `test/js/sql/tls-sql.test.ts` | ASAN | debian 13 x64-asan | | `test/js/node/url/pathToFileURL.test.ts` | ASAN | debian 13 x64-asan | | `test/js/node/fs/abort-signal-leak-read-write-file.test.ts` | ASAN | debian 13 x64-asan | | `test/js/web/streams/streams-leak.test.ts` | ASAN | debian 13 x64-asan | | `test/js/node/test/parallel/test-net-server-listen-path.js` | WINDOWS | windows 2019 x64 + 11 aarch64 | | `test/js/node/test/parallel/test-net-pipe-connect-errors.js` | WINDOWS | fixed in #36786 | | `test/js/node/test/parallel/test-net-client-bind-twice.js` | WINDOWS | fixed in #36786 | | `test/js/node/test/parallel/test-net-server-reset.js` | WINDOWS | fixed in #36786 | | `test/js/bun/io/fetch/fetch-abort-slow-connect.test.ts` | DARWIN | darwin 26 aarch64 + 14 x64 | ### Caveats - `test-tls-connect-memleak.js` and `fetch-abort-slow-connect.test.ts` were `FLAKY` quarantines; both passed in probe build 87834 and confirmation builds 87845 / 87860 / 87868. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 4 · docs-only change; test-proof not applicable <!-- robobun:evidence:end -->
On Windows,
net.connect()reportedECONNREFUSED(orENOENTfor a path connect) for every failure, because the raw WSA code from Winsock was never mapped to theSystemErrnodiscriminant the connect-error whitelist compares against.Repro
All three pass on Linux/macOS.
Cause
Three places on the Windows connect path lose or mis-read the error:
getsockopt(SO_ERROR)read and context.c'srecvprobe) passes raw WSA codes (WSAECONNRESET = 10054,WSAEADDRINUSE = 10048) intohandle_connect_error, whose whitelist is keyed on POSIX-numberedSystemErrnodiscriminants (104, 98). Nothing matched, so every async connect failure fell through toECONNREFUSED.connect_finishafterdo_connect()errs) read the CRT's thread-local_errno(), which Winsock never sets, so it always saw 0 and fell through to the default.bsd.c's AF_UNIX connect path discardedbsd_do_connect_raw's return value and letclosesocket()overwriteWSAGetLastError.Fix
bsd.c: re-arm the WSA error withWSASetLastErrorafterbsd_close_socketin both connect-failure tails, mirroring what thebind-failure path already does.Listener.rsconnect_finish: readWSAGetLastError()(mapped viaSystemErrno::init) on Windows instead oflast_errno().socket_body.rshandle_connect_error: normalize any incoming value>= WSABASEERRthroughSystemErrno::initbefore the whitelist; remap every whitelisted code (not justENOENT/ECONNREFUSED) to itsUV_*value soerr.errnomatches Node.Winsock AF_UNIX returns
WSAECONNREFUSEDfor any path with no listening socket, existing or not, where Node (via libuv'suv_pipe_connect, which usesCreateFile) distinguishesENOENT.connect_finishnow refinesECONNREFUSEDtoENOENTon the failure path when the target path does not exist, so a missing socket file still reportsENOENTwhile an existing non-socket file reportsECONNREFUSED(which the upstream test accepts).Verification
Three Windows-only tests in
test/js/node/net/node-net.test.tscover the EADDRINUSE/ECONNRESET/ENOENT cases directly. The upstreamparallel/test-net-client-bind-twice.js,parallel/test-net-server-reset.js, andparallel/test-net-pipe-connect-errors.jsquarantines are removed.parallel/test-net-pingpong.jsis left quarantined: that failure is the separate named-pipe half-close gap (WindowsNamedPipeissuesuv_closeinstead ofuv_shutdownonend(), andon_read_error(EOF)tears down the writer), which is a larger pipe-writer change.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/net/node-net.test.ts