Repository navigation
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review. WalkthroughChangesThe 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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Full details: Description checkExplanation 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 |
|
Updated 7:45 PM PT - Aug 24th, 2026
❌ @robobun, your commit c580283 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 37104That installs a local version of the PR into your bun-37104 --bun |
|
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. |
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
patches/libuv/win-slow-poll-disconnect.patchscripts/build/deps/libuv.tstest/js/bun/net/socket.test.ts
|
Status: self-review follow-ups are in.
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. |
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
patches/libuv/win-slow-poll-disconnect.patchscripts/build/deps/libuv.tstest/js/bun/net/socket.test.ts
70f9574 to
1fac0b2
Compare
There was a problem hiding this comment.
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_proclap loop: fd-set arming for R/W/D/P, theevents == 0/deadline early-outs, the MSG_PEEK discrimination and its errno exemptions, and the park/suppress-rfds branches — traced against howpackages/bun-usockets/src/eventing/libuv.c'spoll_cbsubscribes 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_socketselection).
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.
5914e38 to
6bf0fec
Compare
|
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. |
There was a problem hiding this comment.
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->eventsre-read is now volatile-qualified; the both-slots-busy graceful return + 1s lap cap converge onto new subscriptions. - Park path:
suppress_rfdsre-select for W|D writers vsSleep(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.
…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>
6bf0fec to
69c3bd2
Compare
There was a problem hiding this comment.
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_procloop: fd-set arming now covers every mask usockets can park with (R/W/D/P), so no empty-setWSAEINVAL; theevents == 0/ deadline early-out and 1s lap cap are placed soselect()never runs with all sets empty. - The park logic:
suppress_rfdsis single-lap and gated onUV_WRITABLEso the sets stay non-empty; theSleep(1000)path only runs when there is nothing else to block on. - Cross-thread
handle->eventsre-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_onceand only steers between two production paths; the six tests each isolate it in a child, wire every failure event to a fastFAIL <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).
2143dd8 to
5f92806
Compare
|
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. |
There was a problem hiding this comment.
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_rfdsone-lap flag and itsUV_WRITABLEguard — 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
finishingflag handles synchronous close fromend().
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.
There was a problem hiding this comment.
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_proclap 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_reqand how the volatile per-lap re-read converges parked requests; theSET_REQ_ERROR(req, ...)slot fix. - The
BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLLhook (uv_once-guarded, only selects between two production paths) and the patch-header format sogit applyactually 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.
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_procinsrc/win/poll.c). That path had two defects for the way usockets usesuv_poll:It never reported
UV_DISCONNECT. usockets armsUV_DISCONNECTon 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_cbinpackages/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.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), handedselect()three empty fd sets. Winsockselect()fails that withWSAEINVALimmediately, 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
Fix
patches/libuv/win-slow-poll-disconnect.patch, touching only the slow path (the AFD fast path, which reportsAFD_POLL_DISCONNECTnatively, is untouched):UV_DISCONNECTorUV_PRIORITIZEDis requested, so the fd sets are never empty and a FIN or RST (which make the socket select()-readable) wakes the poll.UV_PRIORITIZEDis the abort-only watch usockets parks a half-open socket on after its EOF was consumed; an abortive peek error reports it alongsideUV_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.)UV_DISCONNECTrequested, discriminate withrecv(MSG_PEEK): 0 means FIN, an error other thanWSAEWOULDBLOCK/WSAENOTCONN/WSAEMSGSIZEmeans the connection is gone; both reportUV_DISCONNECT.WSAENOTCONNexempts listening sockets (readable on a pending accept, no peer to lose);WSAEMSGSIZEexempts datagrams wider than the 1-byte peek, which a stream socket can never produce. The peek cannot block:uv_poll_init_socketputs 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.select()lap is capped at one second and re-readshandle->events, anduv__slow_poll_submit_poll_req's both-slots-busy branch becomes a graceful return (mirroring the fast path's) instead ofassert(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).poll_req_1even when the failing request waspoll_req_2.BUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL=1hook (read once per process underuv_once, the file's idiom, sinceuv_poll_init_socketruns on every loop's thread) that routesuv_poll_init_socketto 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 ofbun:internal-for-testing. The name follows theBUN_FEATURE_FLAG_FORCE_WAITER_THREADprecedent for release-live env hooks that force a slower production path.When no
UV_DISCONNECTis 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 runsgit applyfromvendor/libuvinside this repo, and git resolvesdiff --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 withBUN_FEATURE_FLAG_UV_FORCE_SLOW_POLL=1:WSAEINVALinsta-close),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,Verification on Windows Server 2019 x64, debug build:
socket.test.tsfile (85 tests) passes both with and without the hook in the environment, andnode-http-connect.test.ts,udp_socket.test.ts(207 tests) anddgram.test.tspass with it forced (general traffic at parity on the slow path: TLS, backpressure, CONNECT tunnels, UDP);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 withFAIL 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