Skip to content

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

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

usockets(win): deliver UV_DISCONNECT on libuv's slow poll path#37104
robobun wants to merge 11 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 is requested, so the fd sets are never empty and a FIN or RST (which make the socket select()-readable) wakes the poll.
  • 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 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

Five 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 sees the peer's answering FIN and closes cleanly, without an error (catches the hung-teardown case),
  • 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).

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.

robobun added 4 commits August 7, 2026 04:51
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.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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.

Possibly related PRs

  • oven-sh/bun#36422: Both PRs address socket disconnect and reset handling with related regression tests.
  • oven-sh/bun#37077: This PR extends Windows/libuv disconnect handling with slow-poll UV_DISCONNECT detection and related tests.
  • oven-sh/bun#37101: Both PRs test paused and half-closed sockets, but target different polling backends.

Suggested reviewers: cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: delivering UV_DISCONNECT on Windows libuv's slow poll path.
Description check ✅ Passed The description explains the problem, fix, affected scope, testing strategy, and verification results in sufficient detail.

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

@github-actions github-actions Bot added the claude label Aug 7, 2026
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.
Comment thread patches/libuv/win-slow-poll-disconnect.patch Outdated
Comment thread test/js/bun/net/socket.test.ts
robobun added 2 commits August 7, 2026 05:44
…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.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:41 AM PT - Aug 7th, 2026

@robobun, your commit 70f9574c9e9a415c0700c311a1f7d9456028fcae passed in Build #89944! 🎉


🧪   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

Addressed the review: the Sleep(1000) park no longer throttles a backpressured writer. When the parked poll also wants UV_WRITABLE, the thread now re-selects with the read set suppressed for that lap (capped at 1s so the disconnect re-peek keeps running), so the send-buffer drain wakes it instantly. A new test parks a receive-paused writer under backpressure with unread data pending: the old code could not move 8MB inside 20s, the fix drains it in ~240ms. Also tightened the RST test to assert the close surfaces dirty. Verified on Windows Server 2019: all 4 slow-poll tests plus the full socket.test.ts file pass.

@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
robobun added 3 commits August 7, 2026 06:02
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.
Comment thread test/js/bun/net/socket.test.ts
… 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.
@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 on lines +4042 to +4049
// Let the paused server poll park with the byte pending (slot 1
// checked out, sleeping between peeks).
await Bun.sleep(400);
paused.resume();
await gotData.promise;
// One settled loop turn so resume's request cycles back to a
// parked readable wait (slot 2 checked out too).
await Bun.sleep(80);

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the wall-clock setup sleeps so the test reliably reaches the both-slots-busy state.

Lines 4044 and 4049 use fixed wall-clock delays to establish the precondition under test. If either window is mistimed on a loaded CI agent, the second poll request is not yet parked when pump(paused) runs. The test then still drains 4 MiB and passes, so a regression in the both-slots-busy branch would go undetected. The failure mode is silent under-coverage, not flakiness.

Line 4049 waits only for a settled loop turn, so Bun.sleep(0) expresses that intent deterministically and satisfies the no-arbitrary-sleep rule. Line 4044 needs the paused poll to park; there is no JS-observable signal for that, so document why the delay is unavoidable, or add an assertion that proves the write actually hit backpressure (for example, assert sent < TOTAL immediately after pump(paused)), so the test fails when the intended path is not exercised.

As per coding guidelines: "Do not use setTimeout or await sleep(N) to wait for conditions; await the event or poll with a deadline." and "Tests must prove they fail for the intended reason and must exercise the actual production guards, constants, environment knobs, and preconditions."

♻️ Proposed change
         await opened.promise;
         client.write("x");
         // Let the paused server poll park with the byte pending (slot 1
-        // checked out, sleeping between peeks).
+        // checked out, sleeping between peeks). No JS-observable signal
+        // exists for "the slow-poll worker has parked", so this delay is
+        // the only way to reach the state under test.
         await Bun.sleep(400);
         paused.resume();
         await gotData.promise;
         // One settled loop turn so resume's request cycles back to a
         // parked readable wait (slot 2 checked out too).
-        await Bun.sleep(80);
+        await Bun.sleep(0);
         // The first partial write re-arms WRITABLE with both slots held:
         // the submission that used to have no slot to land in.
         pump(paused);
+        // Prove the write actually backpressured; otherwise this test would
+        // pass without exercising the both-slots-busy branch.
+        if (sent >= TOTAL) {
+          console.log("FAIL no backpressure: sent " + sent + " without blocking");
+          process.exit(1);
+        }
         client.resume();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Let the paused server poll park with the byte pending (slot 1
// checked out, sleeping between peeks).
await Bun.sleep(400);
paused.resume();
await gotData.promise;
// One settled loop turn so resume's request cycles back to a
// parked readable wait (slot 2 checked out too).
await Bun.sleep(80);
await opened.promise;
client.write("x");
// Let the paused server poll park with the byte pending (slot 1
// checked out, sleeping between peeks). No JS-observable signal
// exists for "the slow-poll worker has parked", so this delay is
// the only way to reach the state under test.
await Bun.sleep(400);
paused.resume();
await gotData.promise;
// One settled loop turn so resume's request cycles back to a
// parked readable wait (slot 2 checked out too).
await Bun.sleep(0);
// The first partial write re-arms WRITABLE with both slots held:
// the submission that used to have no slot to land in.
pump(paused);
// Prove the write actually backpressured; otherwise this test would
// pass without exercising the both-slots-busy branch.
if (sent >= TOTAL) {
console.log("FAIL no backpressure: sent " + sent + " without blocking");
process.exit(1);
}
client.resume();
🤖 Prompt for 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.

In `@test/js/bun/net/socket.test.ts` around lines 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.

Source: Coding guidelines

Comment on lines +29 to +35
+ /* Refreshed every lap, and each select() below waits at most one
+ * second: uv_poll_start/uv_poll_stop on the loop thread change the
+ * subscription mid-flight, and when both requests are checked out
+ * (see uv__slow_poll_submit_poll_req) this lap refresh is the only
+ * thing that converges a parked request onto the new subscription. */
+ events = handle->events;
+

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.

🟡 The per-lap events = handle->events; re-read is now load-bearing cross-thread communication — the both-slots-checked-out branch in uv__slow_poll_submit_poll_req returns early and relies on the worker thread eventually observing the loop thread's later write to handle->events ("re-reads handle->events every lap … so the live requests converge"). That's a plain read concurrent with a plain write with no atomic/volatile/barrier, and REVIEW.md's threading rule is explicit: "Atomics for every shared counter … benign same-value races are still UB." In practice it will always work (the intervening select()/recv()/Sleep()/GetTickCount64() are opaque calls the compiler can't hoist across, and aligned single-byte access is hardware-atomic on every Windows target), so this is not blocking — but a (int) ReadNoFence8((CHAR volatile*)&handle->events) or a volatile-qualified read would make the intended cross-thread visibility well-defined at zero cost.

Extended reasoning...

What the bug is

The rewritten uv__slow_poll_thread_proc reads handle->events at the top of every lap:

for (;;) {
    /* Refreshed every lap … when both requests are checked out
     * (see uv__slow_poll_submit_poll_req) this lap refresh is the only
     * thing that converges a parked request onto the new subscription. */
    events = handle->events;

and the new both-slots-busy branch in uv__slow_poll_submit_poll_req explicitly depends on that read observing a value the loop thread writes after the worker was queued:

} else {
    /* Both requests are checked out. … That loop re-reads handle->events
     * every lap (at most one second), so the live requests converge on
     * the new subscription … */
    return;
}

handle->events is a plain unsigned char field (uv/win.h) with no atomic qualifier, no volatile, and no barrier around the read or the write in uv_poll_start. Under the C memory model, a read on one thread concurrent with a write on another with no synchronization is a data race — undefined behavior. REVIEW.md's thread-affinity rule names this directly: "Atomics for every shared counter (even metrics); a mutex only counts if EVERY accessor takes it; benign same-value races are still UB."

What changed vs upstream

Upstream's single handle->events read at function entry was published-before by QueueUserWorkItem: the loop thread writes handle->events, then queues the work item, then the worker reads it — a clean happens-before chain. (Upstream does already have a milder form of this race: a second uv_poll_start can write handle->events while slot 1's worker is still reading it, and either value is acceptable there.) What's new in this PR is that the correctness of a recovery path — the both-slots-checked-out convergence that test 5 exercises — now depends on the worker observing a post-queue write, rather than merely tolerating either value.

Why existing code doesn't prevent it

There is no volatile qualifier, no Interlocked*/ReadNoFence* on the read, and no release store on the loop-thread write in uv_poll_start. The visibility guarantee rests entirely on implementation behavior of the toolchain and hardware, not on any language-level primitive.

Impact

In practice, none observable — which is why this is a nit, not a blocker:

  1. The load cannot be hoisted. Every lap contains opaque external calls (GetTickCount64(), select(), recv(), Sleep()) that the compiler must assume can write through any escaped pointer, and handle escaped via QueueUserWorkItem. So the compiler cannot cache handle->events in a register across laps.
  2. Single-byte access is hardware-atomic on every Windows target Bun ships (x86-64, aarch64), so no torn reads.
  3. Visibility is bounded. x86-64 TSO plus the intervening kernel calls make the write visible well within the 1s lap; on aarch64 the syscalls provide the fence.
  4. There is a fallback. Even if the worker never observed the update, it eventually completes on its own (socket activity or the 3-minute deadline), and the loop-thread processing step then resubmits with the correct handle->events — just slower than the intended ≤1s convergence.

So the worst case is one extra lap of staleness, not corruption, a hang, or a crash.

Step-by-step proof (the race that's now load-bearing)

  1. Loop thread: uv_poll_start(handle, UV_DISCONNECT) → writes handle->events = UV_DISCONNECT, queues worker A on slot 1. Worker A parks in the Sleep(1000) re-peek loop with an unread byte pending.
  2. Loop thread: uv_poll_start(handle, UV_READABLE|UV_DISCONNECT) → writes handle->events, queues worker B on slot 2. Worker B blocks in select().
  3. Loop thread: uv_poll_start(handle, UV_WRITABLE|UV_DISCONNECT)writes handle->events = 6. Both slot flags are set → uv__slow_poll_submit_poll_req hits the new else branch and simply returns, relying on step 4.
  4. Worker thread A: at the top of its next lap, executes events = handle->events;. This read (worker thread) is concurrent with the write in step 3 (loop thread) with no synchronization primitive between them. The patch's design requires this read to eventually return 6 so worker A arms wfds and delivers UV_WRITABLE.

That is precisely the shape REVIEW.md forbids: cross-thread communication through a plain field.

How to fix it

A one-liner. On the read side:

events = (int) ReadNoFence8((CHAR const volatile*)&handle->events);

or equivalently qualify the read with volatile (events = *(volatile unsigned char*)&handle->events;). If you want strict-C well-definedness rather than just MSVC well-definedness, pair it with a release store on the loop-thread side — but since libuv's own uv_poll_start already writes this field non-atomically and the field is a single byte, the volatile read alone brings the patch to parity with how upstream already treats it, at zero runtime cost.

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