Skip to content

usockets(win): deliver UV_DISCONNECT on libuv's slow poll path - #37104

Open
robobun wants to merge 16 commits into
mainfrom
farm/42532309/libuv-slow-poll-disconnect
Open

robobun wants to merge 16 commits into
mainfrom
farm/42532309/libuv-slow-poll-disconnect

Conversation

@robobun

@robobun robobun commented Aug 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

On Windows, when a winsock LSP (layered service provider) with a non-IFS protocol chain owns a socket (some antivirus/firewall/VPN products install these), libuv cannot AFD-poll it and falls back to its slow poll path: a select() on a worker thread (uv__slow_poll_thread_proc in src/win/poll.c). That path had two defects for the way usockets uses uv_poll:

  1. It never reported UV_DISCONNECT. usockets arms UV_DISCONNECT on every poll and depends on it for connection teardown: it is how a half-closed socket that stopped reading learns of the peer's FIN, and how a paused socket discriminates FIN from abort (poll_cb in packages/bun-usockets/src/eventing/libuv.c). On LSP machines all of that silently degraded: hung teardown (e.g. server.close() waiting forever on a half-closed connection), paused sockets never learning their peer died.

  2. Worse: a poll subscribed to only UV_DISCONNECT, which is the normal parked state for a paused or half-closed socket (pause drops READABLE, the writable dispatch drops WRITABLE once flushed), handed select() three empty fd sets. Winsock select() fails that with WSAEINVAL immediately, which stopped the watcher and reported a bogus error, so usockets error-closed the healthy connection. On an LSP machine, every paused socket dies within one loop iteration.

Winsock semantics verified with a raw C probe on Windows Server 2019:

probe output
1. select with all three sets empty (non-null ptrs, fd_count=0), 300ms timeout:
  select r=-1 err=10022 revents=--- elapsed=0ms        (WSAEINVAL, instant)
2. idle healthy socket, rfds armed: r=0 after timeout  (normal)
3. peer FIN, empty rx buffer: select readable; MSG_PEEK r=0 (repeatably)
4. peer RST, empty rx buffer: select readable; MSG_PEEK err=10054
5. peer sends 3 bytes then FIN: MSG_PEEK r=1           (FIN invisible behind data)
6. peer sends 3 bytes then RST: MSG_PEEK err=10054     (RST discards the queue)
7. closesocket from another thread wakes a blocked select immediately
8. RST wakes a blocked select immediately
9. zero-byte send on a half-open socket: r=0           (healthy)

Fix

patches/libuv/win-slow-poll-disconnect.patch, touching only the slow path (the AFD fast path, which reports AFD_POLL_DISCONNECT natively, is untouched):

  • Arm the read fd set when UV_DISCONNECT or UV_PRIORITIZED is requested, so the fd sets are never empty and a FIN or RST (which make the socket select()-readable) wakes the poll. UV_PRIORITIZED is the abort-only watch usockets parks a half-open socket on after its EOF was consumed; an abortive peek error reports it alongside UV_DISCONNECT. (A reset that lands after the FIN was already consumed is invisible to the socket API itself, probed: peek stays 0, SO_ERROR stays 0, so only the AFD fast path can carry that one.)
  • On a readable wake with UV_DISCONNECT requested, discriminate with recv(MSG_PEEK): 0 means FIN, an error other than WSAEWOULDBLOCK/WSAENOTCONN/WSAEMSGSIZE means the connection is gone; both report UV_DISCONNECT. WSAENOTCONN exempts listening sockets (readable on a pending accept, no peer to lose); WSAEMSGSIZE exempts datagrams wider than the 1-byte peek, which a stream socket can never produce. The peek cannot block: uv_poll_init_socket puts every poll socket in non-blocking mode (FIONBIO, poll.c:419) before a request can exist, and the peek only runs after select() reported readability.
  • The one undeliverable state (data pending on a poll that does not want READABLE: a receive-paused socket) parks and re-peeks once a second instead of hot-spinning the instantly-readable select(). An RST behind the data discards the receive queue and turns the peek into an error within a tick. If the watcher also wants WRITABLE (a backpressured writer), the park re-selects with the read set suppressed for the lap instead of sleeping, so the send-buffer drain still wakes it instantly; without that, the drain test below throttles to one send-buffer per second.
  • Every select() lap is capped at one second and re-reads handle->events, and uv__slow_poll_submit_poll_req's both-slots-busy branch becomes a graceful return (mirroring the fast path's) instead of assert(0). Slow requests are now long-lived by design (a parked DISCONNECT-only request can hold a slot for minutes), so a third subscription generation, e.g. a backpressured write while a parked request holds one slot and a resumed request holds the other, must land somewhere: the live requests converge onto the new subscription within a lap and the processing step resubmits with correct accounting. Previously that sequence hit the assert (or, with asserts compiled out, left the new subscription unarmed until a request completed, up to the full select() timeout).
  • Fixes a pre-existing slot bug where a select() error was recorded on poll_req_1 even when the failing request was poll_req_2.
  • Adds a BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL=1 hook (read once per process under uv_once, the file's idiom, since uv_poll_init_socket runs on every loop's thread) that routes uv_poll_init_socket to the slow path, so the path can be exercised without installing an LSP. The hook only selects between two production paths; the slow path is otherwise unreachable from any CI environment, and this is patched vendored C, out of reach of bun:internal-for-testing. The name follows the BUN_FEATURE_FLAG_FORCE_WAITER_THREAD precedent for release-live env hooks that force a slower production path.

When no UV_DISCONNECT is subscribed, the rewritten thread proc behaves like upstream (same fd set arming, same masking at the processing step, same 3-minute completion cadence; the select just wakes to re-check its subscription once a second), so non-LSP machines see zero behavior change. Upstreamable to libuv/libuv minus the hook.

The patch file deliberately uses traditional --- a/ / +++ b/ headers: the dep fetcher runs git apply from vendor/libuv inside this repo, and git resolves diff --git-style paths against the enclosing repo root and silently skips them with exit 0 (tracked separately; the sibling abort patch is affected by that today).

Tests

Six Windows-only tests in test/js/bun/net/socket.test.ts, each spawning a child with BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL=1:

  • a paused socket with buffered data survives quiescence across loop iterations and resumes (catches the WSAEINVAL insta-close),
  • a half-closed (shutdown()) paused socket survives the peer's answering FIN while parked (the paused-EOF deferral must hold, not error-close) and closes cleanly once reads resume,
  • a paused socket with buffered data learns of a later RST, surfaced as a dirty close (catches the missing abort delivery and exercises the re-peek),
  • a receive-paused backpressured writer with unread data pending drains 8MB at full speed (catches a park that sleeps instead of blocking on writability),
  • a backpressured write landing while both poll request slots are checked out still drains fully (covers the graceful both-slots path and lap convergence).
  • a half-open socket that consumed the peer FIN survives its parked abort-only watch (the broken path error-closes it within two loop iterations), still serves a write, and tears down cleanly.

Verification on Windows Server 2019 x64, debug build:

  • full patch: the 5 tests pass; the whole socket.test.ts file (85 tests) passes both with and without the hook in the environment, and node-http-connect.test.ts, udp_socket.test.ts (207 tests) and dgram.test.ts pass with it forced (general traffic at parity on the slow path: TLS, backpressure, CONNECT tunnels, UDP);
  • hook-only build (hook applied, behavioral hunks removed): the paused-socket tests fail with the instant dirty close (FAIL early-death error=ESHUTDOWN closed=true), the half-close test fails with a dirty close instead of a clean FIN-answered teardown, and the both-slots test fails with FAIL write returned -10054 after 131072 bytes.

POSIX backends are unaffected: they do not use libuv, and kqueue/epoll deliver EOF/HUP natively. There is no way to reproduce the LSP condition itself in CI, which is what the force hook is for; the two-build matrix above stands in for the fail-before the gate cannot run on a Windows-only test.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts

@coderabbitai

coderabbitai Bot commented Aug 7, 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: 2c1aab45-9a99-4465-a1e6-e1e11ea582f0

📥 Commits

Reviewing files that changed from the base of the PR and between bdf4738 and 5f92806.

📒 Files selected for processing (3)
  • patches/libuv/win-slow-poll-disconnect.patch
  • scripts/build/deps/libuv.ts
  • test/js/bun/net/socket.test.ts

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


Walkthrough

Changes

The Windows libuv slow-poll path now detects disconnects, preserves buffered data, limits polling wakeups, and supports forced testing. The dependency patch list and socket tests cover paused reads, half-closes, resets, backpressure, and poll-slot contention.

Windows slow-poll socket handling

Layer / File(s) Summary
Slow-poll worker and forced-path selection
patches/libuv/win-slow-poll-disconnect.patch
The worker refreshes subscriptions, detects disconnects with MSG_PEEK, bounds select waits, handles parked requests, and supports BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL=1.
Libuv patch registration and behavior documentation
scripts/build/deps/libuv.ts
The build applies the Windows libuv patch and documents its disconnect behavior and testing hook.
Forced slow-poll socket regressions
test/js/bun/net/socket.test.ts
Windows tests cover paused buffered reads, peer FIN detection, reset closes, backpressure draining, and contention between poll requests.

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 and concisely identifies the main change: delivering UV_DISCONNECT on the Windows libuv slow poll path.
Description check ✅ Passed The description explains the problem, implementation, scope, tests, and verification results. Although it uses Problem, Fix, and Tests headings instead of the template headings, it provides the requir…
Full details: Description check

Explanation

The description explains the problem, implementation, scope, tests, and verification results. Although it uses Problem, Fix, and Tests headings instead of the template headings, it provides the required information and is substantially complete.


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

@github-actions github-actions Bot added the claude label Aug 7, 2026
Comment thread patches/libuv/win-slow-poll-disconnect.patch Outdated
Comment thread test/js/bun/net/socket.test.ts
@robobun

robobun commented Aug 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:45 PM PT - Aug 24th, 2026

❌ @robobun, your commit c580283 has 1 failures in Build #105243 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37104

That installs a local version of the PR into your bun-37104 executable, so you can run:

bun-37104 --bun

@robobun

robobun commented Aug 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Ready for review. The diff is verified on Windows Server 2019: all six forced-slow-poll tests pass with fail-before demonstrated for each behavioral change, and the full socket.test.ts file is green (98 pass, 0 fail). The remaining CI reds are unrelated to this Windows-only diff (darwin lane timeouts in pre-existing tests and a cpu-prof segfault, all sent to main-break triage); review threads are all resolved.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/net/socket.test.ts`:
- Around line 3891-3898: Update pump to detect a negative result from s.write
before adding it to sent, and fail immediately with an error that identifies the
closed or errored socket condition. Preserve the existing backpressure return
for nonnegative writes where n is less than want, and ensure the failure reaches
the surrounding test flow instead of waiting for drained.promise.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: abae043a-59fc-44b1-9aa0-b28ab3c20dba

📥 Commits

Reviewing files that changed from the base of the PR and between 45eda51 and b5f798e.

📒 Files selected for processing (3)
  • patches/libuv/win-slow-poll-disconnect.patch
  • scripts/build/deps/libuv.ts
  • test/js/bun/net/socket.test.ts

Comment thread test/js/bun/net/socket.test.ts
Comment thread scripts/build/deps/libuv.ts
Comment thread test/js/bun/net/socket.test.ts
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status: self-review follow-ups are in.

  • The park made slow poll requests long-lived, so a backpressured write while a parked DISCONNECT-only request held one slot and a resumed request held the other reached the slow submit's both-slots assert(0) (silent no-op slot loss in practice, W unarmed up to the select timeout). Every select() lap is now capped at one second and re-reads the subscription, and the both-busy branch is a graceful return like the fast path's; a new test drives that exact sequence and requires the full 4MB drain.
  • The force hook is renamed to BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL (matching the FORCE_WAITER_THREAD convention instead of minting a UV_ name) and its one-time read now goes through uv_once, the file's idiom, since uv_poll_init_socket runs on every loop's thread.
  • Both backpressure tests fail fast with the cause on any unexpected socket death (review feedback).

Full matrix re-verified on Windows Server 2019: 5/5 slow-poll tests pass on the fixed build; hook-only build fails the paused-socket tests (instant dirty close), the half-close test, and the both-slots test (write returned -10054 mid-pump). Whole socket.test.ts passes with and without the hook forced; udp_socket.test.ts passes forced.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/net/socket.test.ts`:
- Around line 4042-4049: Replace the fixed 80 ms delay after gotData.promise
with Bun.sleep(0) to wait only for the next settled loop turn. For the 400 ms
delay before paused.resume(), either document why the unavoidable timing wait is
required and immediately assert after pump(paused) that sent is less than TOTAL,
or replace it with an observable condition/deadline poll proving the paused poll
is parked; ensure the test cannot silently pass without exercising the
both-slots-busy path.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 46c91550-e173-4333-8c3f-8c3fca1150a8

📥 Commits

Reviewing files that changed from the base of the PR and between b5f798e and 70f9574.

📒 Files selected for processing (3)
  • patches/libuv/win-slow-poll-disconnect.patch
  • scripts/build/deps/libuv.ts
  • test/js/bun/net/socket.test.ts

Comment thread test/js/bun/net/socket.test.ts Outdated
Comment thread patches/libuv/win-slow-poll-disconnect.patch
@robobun
robobun force-pushed the farm/42532309/libuv-slow-poll-disconnect branch from 70f9574 to 1fac0b2 Compare August 16, 2026 19:02
Comment thread patches/libuv/win-slow-poll-disconnect.patch Outdated

@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.

I've reviewed this PR across several iterations and all prior findings (unsettled failure promises in the backpressure tests, the non-volatile cross-thread handle->events read, and the UV_PRIORITIZED-only empty-fd-set case) have been addressed; this pass found no further bugs. Because it rewrites libuv's Windows slow-poll worker with new cross-thread convergence semantics and a long-lived request model, a human look is still worthwhile before merge.

What was reviewed:

  • The rewritten uv__slow_poll_thread_proc lap loop: fd-set arming for R/W/D/P, the events == 0/deadline early-outs, the MSG_PEEK discrimination and its errno exemptions, and the park/suppress-rfds branches — traced against how packages/bun-usockets/src/eventing/libuv.c's poll_cb subscribes each state.
  • The both-slots-busy graceful return + per-lap volatile re-read as the convergence mechanism, and the SET_REQ_ERROR(req, ...) slot-bug fix.
  • The six Windows-only child-process tests: each wires error/close into the awaited resolver, asserts the specific outcome (dirty vs clean), and the both-slots test now fails if backpressure was never hit.
  • Confirmed the fast (AFD) path and non-Windows builds are untouched (the hook only reroutes peer_socket selection).
Extended reasoning...

Overview

This PR adds a ~260-line patch to vendored libuv (patches/libuv/win-slow-poll-disconnect.patch) that rewrites uv__slow_poll_thread_proc in src/win/poll.c — the select()-based fallback used when a winsock LSP owns a socket and AFD polling is unavailable. It synthesizes UV_DISCONNECT from select() readability + MSG_PEEK, keeps the fd sets non-empty for D-only and P-only subscriptions (previously an instant WSAEINVAL that error-closed healthy paused sockets), caps each select() lap at 1s with a per-lap volatile re-read of handle->events, replaces the both-slots assert(0) with a graceful return, fixes a pre-existing wrong-slot SET_REQ_ERROR, and adds a BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL hook (read once under uv_once) so CI can reach the path. It also registers the patch in scripts/build/deps/libuv.ts and adds six Windows-only, subprocess-spawned tests to test/js/bun/net/socket.test.ts.

Security risks

None identified. No user-controlled input reaches the new code beyond socket bytes already handled by winsock; the 1-byte MSG_PEEK is bounded and non-consuming. The env-var hook only selects between two existing production code paths and is read once. No auth, crypto, or path handling is touched.

Level of scrutiny

High. This is hand-written C patching a vendored dependency's Windows event-loop internals, with a worker thread that now runs a long-lived loop reading state written by the loop thread. The affected path is production (LSP-affected user machines), not test-only, and governs connection lifecycle for every paused/half-closed socket on those machines. The threading model change (long-lived parked requests, both-slots convergence via volatile re-read) is a design decision a maintainer should sign off on, and the 1-second lap cadence / Sleep(1000) park is a latency trade-off worth a human eye. CI cannot exercise the real LSP condition — coverage rests entirely on the force hook, and the fail-before verification was done manually on a Windows box outside CI.

Other factors

This PR has been through four rounds of automated review feedback (mine and CodeRabbit's), all resolved with verified fixes: the backpressure tests now fail fast on socket death, the both-slots test asserts backpressure was actually hit, the cross-thread read is volatile-qualified, and the UV_PRIORITIZED-only case (which I flagged as a same-class regression the PR itself introduced) now arms the read set with a dedicated test. The author deliberately scoped out fixing the sibling win-poll-abort-with-disconnect.patch header (which is silently not applying today) with a sound rationale — activating it would change fast-path behavior and belongs in its own PR. The tests follow harness conventions (concurrent, subprocess-isolated, bunEnv spread, drain pipes concurrently, exit-code asserted last). Given the depth of the change to threading semantics in vendored C and that the true target environment (LSP machines) cannot be reproduced in CI, deferring to a human for final sign-off.

@robobun
robobun force-pushed the farm/42532309/libuv-slow-poll-disconnect branch from 5914e38 to 6bf0fec Compare August 19, 2026 05:26
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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.

I re-reviewed after the UV_PRIORITIZED fix and the volatile subscription read landed — all four rounds of prior feedback are addressed and this pass found no further issues. Given this is ~160 lines of new cross-thread winsock/select logic in a vendored libuv patch that only Windows CI can exercise, a human look is still worthwhile before merge.

What was reviewed:

  • rfds arming now covers UV_READABLE|UV_DISCONNECT|UV_PRIORITIZED; the P-only park no longer hits empty-set WSAEINVAL, and the new half-open abort-watch test covers it.
  • Per-lap handle->events re-read is now volatile-qualified; the both-slots-busy graceful return + 1s lap cap converge onto new subscriptions.
  • Park path: suppress_rfds re-select for W|D writers vs Sleep(1000) for D-only — no throttling, no hot spin; peek errno exemptions (WOULDBLOCK/NOTCONN/MSGSIZE) look right.
  • Tests: every failure event now fails fast with a cause; the both-slots test asserts backpressure was actually hit; the RST test asserts a dirty close.
Extended reasoning...

Overview

This PR patches libuv's Windows-only slow poll path (uv__slow_poll_thread_proc in src/win/poll.c), which is taken when a winsock LSP owns a socket and AFD polling is unavailable. The patch rewrites the worker thread proc to (a) synthesize UV_DISCONNECT from select() readability + MSG_PEEK, (b) arm the read fd set for UV_DISCONNECT/UV_PRIORITIZED-only subscriptions so select() never sees three empty sets, (c) park with a 1s re-peek instead of hot-spinning when data is pending on a receive-paused poll, (d) cap each select() lap at 1s and re-read the subscription so the both-slots-busy branch (now a graceful return, not assert(0)) converges. It also adds a BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL hook (read once under uv_once) and six Windows-only tests in socket.test.ts that spawn children with the hook set. scripts/build/deps/libuv.ts adds the patch to the array with a comment documenting the --- a/ header requirement.

Security risks

None identified. The patch is Windows-only, gated to the slow poll path (LSP machines or the explicit env hook), and only changes what events an already-open socket's poll reports. No new attack surface, no untrusted input parsing, no auth/crypto. The env hook only selects between two existing production paths.

Level of scrutiny

High. This is hand-written C in a vendored dependency touching cross-thread state (handle->events written by the loop thread, read by a worker), winsock select() edge cases, and socket lifecycle on the event loop's critical path. It cannot be exercised on Linux/macOS CI at all — correctness rests on Windows CI plus the author's manual fail-before/pass-after matrix on Windows Server 2019. The prior review round found a real 🔴 bug (P-only subscription still hit the empty-set WSAEINVAL this PR was fixing), so the logic was non-trivially wrong until the most recent commit.

Other factors

This PR has been through four rounds of automated review; every finding (Sleep-throttled writer, unsettled failure promises, non-atomic cross-thread read, missing UV_PRIORITIZED in the rfds mask, sibling patch header format, both-slots test coverage assertion) was addressed with a follow-up commit and Windows re-verification. The author declined to fold in the pre-existing win-poll-abort-with-disconnect.patch header fix, with a sound rationale (activating a never-shipped fast-path change belongs in its own PR). All review threads are resolved. The remaining reason to defer is scope and platform: a ~160-line rewrite of a libuv worker thread proc with threading semantics is not the kind of change to auto-approve, and a maintainer familiar with the usockets/libuv Windows integration should sign off.

Jarred-Sumner pushed a commit that referenced this pull request Aug 19, 2026
…nstead of a code-less error (#39615)

### 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.

<details><summary>Notes</summary>

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.

</details>
@robobun
robobun force-pushed the farm/42532309/libuv-slow-poll-disconnect branch from 6bf0fec to 69c3bd2 Compare August 19, 2026 11:26
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.

All prior review feedback has been addressed (the UV_PRIORITIZED empty-fd-set case, the volatile subscription re-read, the both-slots graceful return, and the test fail-fast wiring in 2143dd8), and this pass found no new issues. Because this is a ~260-line rewrite of a vendored libuv thread proc with cross-thread state and Winsock edge semantics that only runs on Windows LSP machines, a human look is still worthwhile before merging.

What was reviewed:

  • The rewritten uv__slow_poll_thread_proc loop: fd-set arming now covers every mask usockets can park with (R/W/D/P), so no empty-set WSAEINVAL; the events == 0 / deadline early-out and 1s lap cap are placed so select() never runs with all sets empty.
  • The park logic: suppress_rfds is single-lap and gated on UV_WRITABLE so the sets stay non-empty; the Sleep(1000) path only runs when there is nothing else to block on.
  • Cross-thread handle->events re-read is now volatile-qualified; the both-slots-busy branch returns gracefully and the lap convergence bounds the delay to 1s.
  • The env hook reads once under uv_once and only steers between two production paths; the six tests each isolate it in a child, wire every failure event to a fast FAIL <cause> exit, and assert their preconditions (backpressure actually hit, park actually survived).
Extended reasoning...

Overview

This PR patches vendored libuv's Windows slow-poll path (src/win/poll.c) — the select()-on-a-worker-thread fallback taken when a winsock LSP owns the socket and AFD polling is unavailable. It rewrites uv__slow_poll_thread_proc into a lap loop that synthesizes UV_DISCONNECT from readability + MSG_PEEK, arms the read set for UV_DISCONNECT/UV_PRIORITIZED watchers (fixing an instant WSAEINVAL that error-closed every paused socket), parks instead of hot-spinning when data is pending on a receive-paused poll, and turns the both-slots-busy assert(0) into a graceful return with 1s lap convergence. It also adds a BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL hook (read once under uv_once) so the path is testable without an LSP, wires the patch into scripts/build/deps/libuv.ts, and adds six Windows-only child-process tests to socket.test.ts.

Security risks

None identified. The change is confined to a Windows-only fallback poll path in a vendored dependency; it does not touch auth, crypto, permissions, or untrusted-input parsing. The env hook only selects between two existing production code paths and is read once per process. The MSG_PEEK probe reads one byte into a stack local and its error codes are whitelisted rather than blindly propagated.

Level of scrutiny

High. This is native C in a vendored dependency, rewriting a thread proc that runs concurrently with the loop thread and depends on subtle Winsock semantics (empty-fd-set select() behavior, post-FIN readability, RST-discards-queue, MSG_PEEK error codes on listening/datagram sockets). The path is unreachable on any non-LSP Windows machine without the force hook, so CI coverage is entirely dependent on the hook and the author's manual Windows Server 2019 verification. Cross-thread correctness (the volatile handle->events re-read that the both-slots convergence rests on) and the park/suppress interaction with backpressure both required review iterations to get right on this PR — an earlier revision had a genuine correctness bug (P-only mask left all fd sets empty) that would have shipped the exact bug class the PR fixes.

Other factors

The PR has been through four review rounds (mine and CodeRabbit's), each producing fixes that were verified on a real Windows machine with a hook-only control build. All threads are resolved. The tests now wire every failure event to a fast-fail exit, assert preconditions (backpressure hit, park survived), and the fixed sleeps that remain are documented as having no JS-observable substitute. The AFD fast path is untouched, and the sibling win-poll-abort-with-disconnect.patch header issue was deliberately left out of scope with a rationale in the PR body. Given the depth of platform-specific reasoning here (Winsock LSP behavior, AFD vs select() semantics, libuv's two-slot poll request model), a maintainer familiar with the Windows eventing layer should sign off.

The select()-based slow poll path (taken when a winsock LSP with a
non-IFS protocol chain owns the socket) never reported UV_DISCONNECT,
and a poll subscribed to only UV_DISCONNECT handed select() three empty
fd sets, which fails WSAEINVAL immediately and stopped the watcher with
a bogus error. usockets arms UV_DISCONNECT on every poll and parks
paused and half-closed sockets in exactly that state, so on LSP machines
healthy paused sockets were error-closed with ECONNRESET within one loop
iteration, and peer FIN/RST for quiesced sockets was never delivered.

Synthesize UV_DISCONNECT on the slow path from select() readability plus
MSG_PEEK discrimination (0 = FIN, error other than WSAEWOULDBLOCK or
WSAENOTCONN = dead connection), pace the one undeliverable state (data
pending on a DISCONNECT-only poll) with a 1s re-peek instead of a hot
respin, and add the UV_FORCE_SLOW_POLL=1 hook so tests reach the path
without installing an LSP.
git apply resolves git-style (diff --git) paths against the enclosing
repo root when run from vendor/<dep> inside this repo, and silently
skips them with exit 0, so the patch would never actually apply.
Traditional --- a/ +++ b/ headers are resolved relative to the cwd and
apply correctly.
Track the close callback's error argument and delay the client's
answering FIN behind allowHalfOpen, so a build whose slow path
error-closes the parked socket fails the half-close test instead of
racing a same-tick clean teardown past it.
A 1-byte MSG_PEEK on a UDP socket with a datagram wider than the buffer
fails with WSAEMSGSIZE; a stream socket can never produce it, so treating
it as a dead connection misreported every multi-byte datagram on a
DISCONNECT-armed UDP poll.
…wanted data

A poll subscribed to UV_WRITABLE | UV_DISCONNECT (a receive-paused socket
writing under backpressure) with unread data pending made select() return
instantly via the read set, and the fall-through parked it with
Sleep(1000): writable wakes were throttled to one per second instead of
firing the instant the send buffer drained. Re-select() with the read set
suppressed for that lap instead, capped at one second so the disconnect
re-peek keeps running behind the pending data.
…r park

The reset test captured the error/close discriminators but never asserted
them; an RST behind buffered data must surface as a dirty close. The new
drain test parks a receive-paused writer under backpressure with unread
data pending: a slow path that sleeps through that state cannot move 8MB
inside the timeout.
Self-review findings, merged with the writability-park refinement:

- The park makes slow poll requests long-lived, so a third subscription
  generation (a backpressured write while a parked DISCONNECT-only
  request holds one slot and a resumed request holds the other) reached
  uv__slow_poll_submit_poll_req's both-slots assert(0): an abort in
  debug builds, and in release the new subscription stayed unarmed
  until a request completed, up to the full select() timeout. Cap every
  select() lap at one second and re-read handle->events each lap so
  live requests converge onto subscription changes within a tick, and
  make the both-busy branch a graceful return like the fast path's.

- Replace the bare static lazy init of the force hook with uv_once (the
  file's own idiom; uv_poll_init_socket runs on every loop's thread),
  and rename the variable to BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL: Bun
  should not mint names in the UV_ namespace it does not own, and
  BUN_FEATURE_FLAG_FORCE_WAITER_THREAD is the established convention
  for a release-live env hook that forces a slower production path.

- New test covering the slot exhaustion sequence.
A partial first chunk satisfied the old got-some assertion even when the
re-armed WRITABLE subscription stalled; pump the payload through drain
and assert every byte arrives.
… tests

A socket death in either drain test left the child waiting on the
drained promise until the parent timeout with nothing on stdout; route
every unexpected error/close straight to a FAIL line and exit.
…cription read

Swap the 80ms pre-pump delay for a single settled loop turn, document
why the 400ms park window has no observable substitute, and fail if the
flood never hit backpressure (nothing re-armed WRITABLE in that case,
so the both-slots branch went unexercised).

The per-lap handle->events re-read is what converges a parked request
onto subscription changes made by the loop thread, including the
both-slots-busy recovery: make that cross-thread read volatile instead
of relying on the opaque calls in the lap to keep the compiler from
caching it.
Since the paused-EOF deferral landed on main, a receive-paused socket
that already sent its FIN intentionally does not close when the peer's
FIN arrives (the unread tail would be lost); the EOF rides the readable
path at resume. Rework the half-close test to hold the parked state
across the FIN (still catching the WSAEINVAL insta-close), then resume
and require the clean close. Verified on Windows that the fast and slow
paths agree at every step.
usockets parks a half-open socket whose EOF was already consumed on an
abort-only watch (uv_poll_start with just UV_PRIORITIZED), and that mask
armed no fd set on the slow path: select() failed WSAEINVAL and
error-closed the healthy socket, the same class as the DISCONNECT-only
bug. Arm the read set for it, report UV_PRIORITIZED alongside
UV_DISCONNECT when the peek shows an abortive death, and park on the
permanent post-FIN readability otherwise.

A reset that arrives after the FIN was consumed is invisible to the
socket API itself (probed: peek stays 0, SO_ERROR stays 0, zero-byte
send succeeds), so only AFD's event stream can deliver that case; the
slow path survives the park and delivers what a peek can see.

New test: the half-open socket survives the parked abort watch, still
serves a write, and tears down cleanly. Fails on the previous patch
revision with an ESHUTDOWN error-close inside two loop iterations.
The client error/close handlers in the first and sixth slow-poll tests
only recorded nothing: a client death mid-exchange hung the awaited
reply promise until the parent timeout with the cause unprinted. Route
them to a FAIL line and exit, check the single write() returns, and
guard the close handler for the clean teardown (end() can dispatch
close synchronously).
@robobun
robobun force-pushed the farm/42532309/libuv-slow-poll-disconnect branch from 2143dd8 to 5f92806 Compare August 25, 2026 01:34
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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.

I reviewed this PR and the bug-hunting pass found nothing new; all prior review threads (the UV_PRIORITIZED empty-fd-set miss, the volatile cross-thread read, the test fail-fast wiring, the both-slots backpressure assertion) are addressed and verified on Windows. Given that this is a ~160-line rewrite of libuv's uv__slow_poll_thread_proc — a worker-thread loop with cross-thread handle->events reads, a semantics change to the both-slots-busy branch, and a new production env hook — a human look is still worthwhile before merge.

What was reviewed:

  • The rewritten select()/peek loop for empty-fd-set states (D-only, P-only, W|D with pending data) — all now arm rfds or park without WSAEINVAL.
  • The suppress_rfds one-lap flag and its UV_WRITABLE guard — sets stay non-empty on the suppressed lap.
  • The both-slots graceful return + per-lap volatile re-read convergence, and the SET_REQ_ERROR(req, ...) slot fix.
  • All six Windows-only tests: every failure event now settles the awaited promise or exits with a cause; the finishing flag handles synchronous close from end().
Extended reasoning...

Overview

This PR adds a 266-line patch to vendored libuv (patches/libuv/win-slow-poll-disconnect.patch) that rewrites uv__slow_poll_thread_proc in src/win/poll.c — the select()-based fallback path Windows takes when a non-IFS winsock LSP owns a socket. It also registers the patch in scripts/build/deps/libuv.ts and adds six Windows-only subprocess tests to test/js/bun/net/socket.test.ts gated on a new BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL=1 hook.

The behavioral changes: arm the read fd set for UV_DISCONNECT/UV_PRIORITIZED (fixing WSAEINVAL on empty fd sets), synthesize UV_DISCONNECT from MSG_PEEK, park instead of hot-spinning when data is pending on a receive-paused poll (with a writability-aware park), cap each select() lap at 1s and re-read handle->events via volatile so parked requests converge onto new subscriptions, replace the both-slots-busy assert(0) with a graceful return, and fix a pre-existing slot bug where errors were recorded on poll_req_1 regardless of which request failed.

Security risks

None identified. The change is confined to Windows socket poll event delivery; no parsing of untrusted input, no auth/crypto/permissions surface. The new env var is a test hook that only selects between two existing production code paths.

Level of scrutiny

High. This is a substantial rewrite of a libuv worker-thread procedure that runs concurrently with the loop thread, with load-bearing cross-thread communication (the volatile handle->events re-read is what the both-slots convergence rests on). It changes the semantics of a previously-unreachable assert(0) branch to a graceful return whose correctness depends on that convergence. It affects real users on LSP-affected Windows machines (antivirus/VPN/firewall products), and the AFD fast path — the path every CI machine takes — is untouched, so CI cannot exercise the changed code without the force hook. The PR description is thorough and the author verified fail-before/pass-after on Windows Server 2019 across multiple iterations, but this is exactly the kind of vendored-native-code + threading + platform-specific change the review guidelines flag for human sign-off.

Other factors

This PR has been through five review rounds. I previously flagged a real bug (the UV_PRIORITIZED-only mask left all fd sets empty, reintroducing the WSAEINVAL failure the PR set out to fix) which was confirmed reproduced and fixed in 5914e38/b04a7f2e; three test-quality nits (fail-fast wiring, volatile read, backpressure precondition assertion) were also addressed. All inline threads are resolved. The tests follow harness conventions (describe.concurrent.skipIf(!isWindows), bunEnv spread, subprocess drain via Promise.all, stdout asserted before exitCode). The remaining fixed Bun.sleep(1500)/Bun.sleep(400) windows are documented as having no JS-observable substitute for "the slow-poll worker has parked", which is accurate — and each is followed by a state check that fails fast on the primary regression, so they are liveness assertions rather than blind waits.

Given the scope (vendored libuv, cross-thread semantics, Windows-only path unreachable from default CI), I'm deferring rather than approving.

@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.

I reviewed this PR across four rounds and the current revision addresses everything raised (Sleep-throttled backpressure, both-slots assert, the UV_PRIORITIZED-only WSAEINVAL, the volatile subscription read, and fail-fast wiring in all six tests); this run found nothing new. Because it's a substantial rewrite of libuv's Windows slow-poll worker thread — cross-thread state, park/sleep semantics, and connection-teardown behavior for every socket on LSP machines — a human sign-off is still warranted.

What was reviewed:

  • The rewritten uv__slow_poll_thread_proc lap loop: fd-set arming for R/D/P, the MSG_PEEK discrimination and its errno exemptions, the suppress_rfds writability park, the 1s lap cap and 3-minute deadline.
  • The both-slots-busy graceful return in uv__slow_poll_submit_poll_req and how the volatile per-lap re-read converges parked requests; the SET_REQ_ERROR(req, ...) slot fix.
  • The BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL hook (uv_once-guarded, only selects between two production paths) and the patch-header format so git apply actually applies it.
  • The six Windows-only child-process tests: every awaited promise now has a failure-path settle, write() returns are checked, and the both-slots test asserts backpressure was hit.
Extended reasoning...

Overview

The PR adds a ~266-line patch to vendored libuv's src/win/poll.c that rewrites uv__slow_poll_thread_proc (the select()-based fallback path used when a winsock LSP owns a socket) to synthesize UV_DISCONNECT from readability + MSG_PEEK, arm the read set for D/P-only subscriptions so select() never gets three empty fd sets, cap each select() lap at 1s with a volatile re-read of handle->events, park (sleep or suppress-rfds re-select) when only undeliverable readability is pending, and turn the both-slots-checked-out assert(0) into a graceful return. It also fixes a pre-existing wrong-slot SET_REQ_ERROR, adds a BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL env hook so the path is testable without an LSP, wires the patch into scripts/build/deps/libuv.ts, and adds six Windows-only subprocess tests (~540 lines) to test/js/bun/net/socket.test.ts.

Security risks

None identified. The change is Windows-only, affects only the slow-poll fallback (the AFD fast path is untouched), and the env hook only selects between two production code paths. The 1-byte MSG_PEEK runs on a socket libuv already put in non-blocking mode and only after select() reported readability, so it cannot block the worker thread. No new attack surface, no user-controlled parsing.

Level of scrutiny

High. This is a hand-written C patch to a vendored dependency's threading-sensitive code: a worker-thread proc that reads loop-thread-mutated state, with park/sleep decisions that affect connection teardown for every socket on affected Windows machines. The AFD fast path is unchanged so most Windows users are unaffected, but the LSP population is exactly who this targets and getting it wrong means hung teardown or spurious error-closes. The four prior review rounds each found a real defect (backpressure throttle, reachable assert, an unhandled subscription bit, missing fail-fast wiring), which is itself evidence the change is subtle enough to merit maintainer eyes.

Other factors

All prior review threads are resolved with empirical Windows Server 2019 verification (fail-before/pass-after on a hook-only build, full socket.test.ts/udp_socket.test.ts/node-http-connect.test.ts green with the hook forced). The tests are well-constructed after iteration: every failure event settles the awaited promise, the both-slots test asserts backpressure was actually hit, the fixed sleeps that remain are documented as having no JS-observable substitute. The PR body is unusually thorough (raw winsock probe output, per-case rationale, upstreamability note). The bug-hunting system found nothing this run. I'm deferring rather than approving only because a rewrite of a libuv worker thread with cross-thread convergence semantics is the kind of change a maintainer should own the decision on.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant