Skip to content

usockets(win): surface a dead connection behind a paused socket's deferred empty-buffer FIN - #37097

Open
robobun wants to merge 3 commits into
mainfrom
farm/f054894e/win-paused-fin-rst-strand
Open

usockets(win): surface a dead connection behind a paused socket's deferred empty-buffer FIN#37097
robobun wants to merge 3 commits into
mainfrom
farm/f054894e/win-paused-fin-rst-strand

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Symptom

On Windows, a paused socket whose peer sends a clean FIN while the socket's receive buffer is empty, and whose connection later dies, never gets an error/close callback. The socket strands until JS happens to resume it or an idle timeout reaps it, forever for timeout-less net sockets (receive backpressure in Bun.serve body streams and net.Socket#pause are the common ways to be paused). On Linux the same sequence closes the socket via the unmaskable EPOLLERR.

Cause

Two defects in the libuv backend's fin_deferred sweep machinery (the 4s sweep that probes paused sockets whose FIN report was already consumed):

  1. fin_deferred was never initialized. It shares a byte with unclassified_send_failures:7 in us_socket_t, and every socket init site set only the 7-bit field, so the flag started as malloc garbage. Garbage 1s made the existing peeked > 0 latch in poll_cb skip its fin_deferred_count increment, while close/resume decremented a count that was never incremented, driving it negative. The sweep escalation is gated on fin_deferred_count > 0, so it never ran at all; the machinery was effectively dead.

  2. The peeked == 0 path never latched. poll_cb's paused-socket probe (packages/bun-usockets/src/eventing/libuv.c) marked a socket for the sweep only when the FIN arrived behind buffered data. A clean FIN on an empty buffer deferred the eof (pause contract) without latching, and the one-shot UV_DISCONNECT report was consumed by that dispatch: the poll re-arms without UV_DISCONNECT, a paused socket's events have converged to none, so nothing is subscribed that could ever report the connection dying later, and the sweep did not know to probe it.

Fix

  • Initialize s->fin_deferred = 0 at all four socket birth sites (listen-socket init, connect-socket init, the accept path, us_socket_from_fd), making the count sane and the existing sweep machinery actually run. us_socket_detach now clears a latched flag too, since a detached fd leaves usockets' management without passing through us_internal_socket_close_raw.
  • Latch fin_deferred in the peeked == 0 branch too, so the sweep probes the socket and escalates once the connection is dead, closing it with LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET exactly like the buffered-data case.
  • Tighten us_internal_libuv_peer_reset_probe from a denylist (anything but WSAEWOULDBLOCK/WSAESHUTDOWN counted as peer-gone) to the WSA mirror of us_internal_send_errno_is_peer_gone's allowlist. This matters more now that the sweep actually runs: WSAENOBUFS is documented transient-on-healthy in this codebase (bsd_send_is_transient_error), and a false positive reset-closes a healthy paused connection, while a missed detection self-heals at the next 4s sweep.

A healthy half-closed peer keeps the socket deferred: the sweep's probe succeeds and the eof still waits for resume().

One Windows TCP finding shaped the test: Windows does not emit an RST when a socket that already sent its FIN is aborted (verified at the winsock level: after abort from FIN_WAIT_2, the victim's zero-byte send succeeds, SO_ERROR is 0, and recv reports the graceful EOF), so a fully idle loopback victim has no TCP-level signal of the peer's death. The tests have the victim write one byte into the dead connection; the RST reply to that segment resets the victim's TCB the same way a remote peer's RST segment would, which is the state the sweep probe detects.

Platform status

  • Linux (epoll): safe without this machinery; the kernel's unmaskable EPOLLERR/EPOLLHUP closes the paused victim milliseconds after the reset (verified on a debug build of this branch). The C changes here are inert on the epoll path.
  • macOS (kqueue): the same paused + empty-buffer FIN scenario strands by analysis, and worse (the FIN itself is never delivered): pause converges the socket to zero registered filters, kqueue has no unmaskable event class, and no sweep exists on that backend. The rescue mechanism in this PR (AFD zero-byte-send probe sweep) is libuv-specific, so the kqueue counterpart is out of scope here and tracked separately. usockets: stop spinning on a half-open socket whose peer resets behind pending writes (kqueue) #37077 covers only the kqueue flavor where pending writes keep EVFILT_WRITE armed.

Verification

On Windows x64, three Windows-gated tests in test/js/bun/net/socket.test.ts:

  • "paused socket with a deferred empty-buffer FIN still closes when the connection later dies": fails on the released build (victim stranded 12s after the connection died), passes with this fix (closes within one sweep period, ~2.7s after the death, end never fires, and the clean FIN alone stays deferred across a full sweep).
  • "paused socket with a FIN deferred behind buffered data still closes when the connection later dies": covers the pre-existing peeked > 0 latch that the init fix first makes reachable, asserting the reset wins and neither the buffered data nor end is delivered (node parity for a paused socket whose peer died). On the released build this path depends on malloc garbage, so it strands nondeterministically; the deterministic fail-before proof is the empty-buffer test.
  • "resuming a paused socket delivers the data and FIN that were deferred while paused": pins the resume side of the contract (deferred data + end delivered, clean close) and the count hand-back from the sweep.

test/js/bun/net/socket.test.ts on Linux: same pass/fail set as main (the new tests are Windows-only).

Adjacent: #37077 fixed the unpaused flavor of this FIN-then-RST family for kqueue; #34487/#35939 cover the delivered-end (readable_ended) flavor. This PR is the paused case plus the initialization defect that kept the whole sweep off.


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

…erred empty-buffer FIN

Two defects in the libuv backend's fin_deferred sweep machinery:

1. fin_deferred was never initialized at socket creation. It shares a
   byte with unclassified_send_failures:7 and every init site set only
   the 7-bit field, so the flag started as malloc garbage. Garbage 1s
   made the existing peeked>0 latch skip its fin_deferred_count
   increment while close/resume decremented a count that was never
   incremented, driving it negative - and the sweep escalation is gated
   on fin_deferred_count > 0, so it never ran at all. Initialize the
   flag at all four socket birth sites (listen, connect, accept,
   from_fd).

2. poll_cb's paused-socket probe latched fin_deferred only in the
   peeked > 0 branch (FIN behind buffered data). A clean FIN arriving
   with an empty receive buffer (peeked == 0) deferred the eof without
   latching, so the consumed one-shot DISCONNECT left the poll with no
   subscription and the sweep never probed the socket: when the
   connection later died there was no event left to ride, and the
   socket stranded with no error/close until JS resumed it or an idle
   timeout fired - forever for timeout-less net sockets. Latch in the
   peeked == 0 branch too.

Verified on Windows: the new socket.test.ts case strands under the
released build (victim never closes after the peer is gone) and closes
within one sweep period (~2.7s) with the fix. The clean FIN alone still
stays deferred across a sweep (pause contract), and end does not fire
for the dead socket.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e9b288c5-4b03-4e56-94a6-0bda206265c8

📥 Commits

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

📒 Files selected for processing (5)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/eventing/libuv.c
  • packages/bun-usockets/src/loop.c
  • packages/bun-usockets/src/socket.c
  • test/js/bun/net/socket.test.ts

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

@github-actions github-actions Bot added the claude label Aug 7, 2026
@robobun

robobun commented Aug 7, 2026

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

@robobun, your commit 1ee4c37c759e69f1767d5c4b9f8e468122900638 passed in Build #89930! 🎉


🧪   To try this PR locally:

bunx bun-pr 37097

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

bun-37097 --bun

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. usockets: re-arm readable in raw_shutdown after read_eof so the close is delivered on Windows #34487 - Adds the identical four s->fin_deferred = 0; initializations and its own fin_deferred latch + fin_deferred_count++ in poll_cb's UV_DISCONNECT handling, fixing the same Windows "consumed one-shot DISCONNECT leaves nothing to carry a later reset" defect from the readable_ended state instead of the paused empty-buffer state.
  2. usockets: stop spinning on a half-open socket whose peer resets behind pending writes (kqueue) #37077 - Adds the same four s->fin_deferred = 0; initializations and rewrites the same poll_cb UV_DISCONNECT block for the same problem, but via a competing UV_PRIORITIZED/AFD-ABORT subscription rather than the fin_deferred sweep, so the two designs collide on the same code.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Not duplicates, but both are in the same FIN-then-reset family and worth cross-referencing:

One point from this PR that affects how the other two are judged: without the init fix, fin_deferred_count goes negative from garbage bits (close/resume decrement a count that was never incremented), and the sweep escalation is gated on count > 0, so on current main the sweep machinery never runs at all. Any PR relying on the sweep needs these inits to function.

@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 didn't find any bugs, but this touches core usockets eventing on Windows and — by fixing the fin_deferred init — activates the sweep escalation loop in sweep_timer_cb that was effectively dead before, so the blast radius is wider than the one symptom described. Worth a human look.

Checked: the hoisted us_internal_poll_cb_adopted_socket call is safe (probeable already dereferences it); the new peeked == 0 latch is balanced by the existing decrements in us_socket_resume / us_internal_socket_close_raw; the four init sites cover every us_socket_t birth (accept, connect, listen, from_fd); the added stores compile on non-libuv paths (fin_deferred is unconditionally declared in internal.h).

Extended reasoning...

Overview

Two fixes to the Windows (libuv) backend's paused-socket FIN-deferral machinery in packages/bun-usockets/:

  • Initialize the fin_deferred:1 bitfield to 0 at all four socket birth sites (context.c ×2, loop.c accept path, socket.c from_fd). Previously only the adjacent unclassified_send_failures:7 in the same byte was set, leaving fin_deferred as malloc garbage.
  • In libuv.c poll_cb, latch fin_deferred in the peeked == 0 branch (clean FIN, empty rx buffer) the same way the existing peeked > 0 branch does, and hoist the sock lookup above the branch chain.
  • New Windows-only concurrent test in socket.test.ts (~17s of structural sleeps, 40s timeout) that pauses a server socket, has the peer FIN, waits a full 4s sweep to assert the FIN alone stays deferred, then kills the peer and writes into the dead connection to provoke the RST the sweep detects.

Security risks

None. No parsing of untrusted input, no auth/crypto. The zero-byte send probe and the sweep loop are pre-existing; this PR only feeds one more state into them.

Level of scrutiny

High. This is low-level C socket lifecycle code where a miscounted fin_deferred_count or a mis-latched flag can hang or prematurely close connections. Critically, the init fix means sweep_timer_cb's escalation loop — which per the PR description never ran before because garbage fin_deferred bits drove the count negative — will now actually execute on Windows for both the pre-existing peeked > 0 case and the new peeked == 0 case. That's a behavioral activation with wider reach than the single symptom, on a platform I can't exercise here.

Other factors

  • The reasoning in the PR description and code comments is thorough and internally consistent; the count balancing checks out (latch guarded by !sock->fin_deferred; resume/close_raw each decrement once and clear; sweep clears before calling close_raw so no double-decrement).
  • The test's sleeps are justified in comments as structural (fixed 4s sweep cadence, absence-of-close assertion), which is the documented exception in REVIEW.md, but it's still a ~17s Windows-only timing test — CI will be the real verification.
  • us_socket_detach does not clear fin_deferred (pre-existing, not introduced here), but detach is a niche path and the field is Windows-only; noting for completeness, not as a blocker.
  • No prior human or bot review on this PR; CI build was still in progress at review time.

A detached socket leaves usockets' management without going through
us_internal_socket_close_raw, so a latched fin_deferred would leak the
count and keep the sweep walking the group list every tick forever.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on us_socket_detach: a detached socket bypasses us_internal_socket_close_raw, so a latched fin_deferred would leak the count and keep the sweep walking every tick. Added the same guarded clear there in 6059572 (verified the Windows test still passes). The other checks match my analysis: the latch/decrement sites balance, and the four init sites cover every us_socket_t birth.

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

No issues found by the bug hunt, but this touches core usockets socket-lifecycle C code (every accept/connect init, the Windows poll_cb dispatch, the sweep counter invariant) and overlaps in design with two other open PRs (#34487, #37077) on the same fin_deferred/UV_DISCONNECT machinery — a human should decide which approach lands and in what order.

What was reviewed:

  • fin_deferred count balance: latch sites (both poll_cb branches) vs. decrement sites (close_raw, resume, detach, sweep) — balanced; us_socket_adopt's memcpy carries the flag to the new allocation and the old goes to the closed list without a spurious decrement.
  • All four socket birth sites now init the bitfield; us_socket_from_fd is Windows-unreachable (returns 0) so its store is inert.
  • Test resource cleanup on mid-test assertion failure — ruled out (the peer connect is awaited and terminate()d on the happy path; the using server covers the listener).
Extended reasoning...

Overview

The PR fixes a Windows-specific socket stranding bug in the usockets libuv backend: a paused socket that receives a clean FIN on an empty receive buffer, and whose connection later dies, never gets a close callback. The fix has two parts: (1) initialize the fin_deferred bitfield at all four us_socket_t birth sites (it shares a byte with unclassified_send_failures:7 and was previously malloc garbage, which drove fin_deferred_count negative and disabled the sweep entirely), and (2) latch fin_deferred in poll_cb's peeked == 0 branch so the 4s sweep probes the socket after the one-shot UV_DISCONNECT has been consumed. A follow-up commit adds the same guarded clear to us_socket_detach so a detached socket doesn't leak the count. A Windows-gated test in socket.test.ts reproduces the strand.

Security risks

None identified. This is event-loop plumbing on already-accepted sockets; no parsing of untrusted input, no auth/crypto, no user-controlled sizes. The new stores are to a 1-bit field on a struct we own.

Level of scrutiny

High. This is core C in the networking hot path: every accepted socket, every connect socket, and every Windows poll dispatch runs through the touched code. The fin_deferred_count invariant spans five files and multiple lifecycle exits (close, resume, detach, adopt, sweep), and getting it wrong either strands sockets or makes the sweep walk every group every 4s forever. The change is small and mechanically consistent with the existing peeked > 0 latch, but the counter bookkeeping is exactly the kind of cross-file invariant that benefits from a maintainer's eyes.

Other factors

  • Competing open PRs: #34487 and #37077 both add the identical four init lines and touch the same poll_cb UV_DISCONNECT block with different (and in #37077's case, structurally competing) approaches. The author has explained how they compose, but which design wins for the non-paused quiesce case is a maintainer call, and merge order matters.
  • Test timing: the new test is ~17s of wall clock on Windows (structural 4.6s + 12s bounded race against a fixed 4s sweep cadence). The sleeps are commented per REVIEW.md's requirement, but a maintainer may want to weigh the CI cost.
  • CI: build #89902 is still running per the timeline; Windows results not yet visible.

… buffered-FIN and resume paths

us_internal_libuv_peer_reset_probe treated any send() error other than
WSAEWOULDBLOCK/WSAESHUTDOWN as peer-gone. WSAENOBUFS is documented
transient-on-healthy (bsd_send_is_transient_error), and a false positive
reset-closes a healthy paused connection, so mirror
us_internal_send_errno_is_peer_gone's allowlist instead. The asymmetry
favors this: a missed detection self-heals at the next 4s sweep, a false
positive is unrecoverable.

Tests: add the buffered-data sibling (the pre-existing peeked > 0 latch
that the init fix first makes reachable) asserting the reset wins over
the deferred data and end, and a resume-delivery case pinning that a
resumed socket gets its deferred data + end with a clean close.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review follow-ups, pushed in 1ee4c37:

  • Probe tightened to an allowlist. The sweep probe counted any send() error besides WSAEWOULDBLOCK/WSAESHUTDOWN as peer-gone, so a transient WSAENOBUFS (documented transient-on-healthy in bsd_send_is_transient_error) could reset-close a healthy paused connection once the sweep actually runs. It now mirrors us_internal_send_errno_is_peer_gone's allowlist; a missed detection self-heals at the next 4s sweep, a false positive would not.
  • Buffered-data coverage. The init fix newly activates the pre-existing peeked > 0 latch, which had no test; added the sibling test (data, then FIN, then death) asserting the reset wins over the deferred data and end.
  • Resume coverage. Added a test pinning that a resumed socket receives its deferred data + end with a clean close (also the count hand-back path).
  • macOS status stated in the PR body. The kqueue backend strands in the same scenario (pause converges to zero filters, no unmaskable class, no sweep there); the rescue here is AFD-specific, so the kqueue counterpart is tracked separately rather than widened into this PR.

All three tests pass on Windows x64 with the fix; the empty-buffer one remains the deterministic fail-before proof on the released build.

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

No bugs found, but this touches Windows socket-lifecycle C code (fin_deferred sweep counter balance, close/detach/resume paths, and a denylist→allowlist change in the peer-reset probe) and overlaps with two open PRs (#34487, #37077) on the same machinery — worth a human look to confirm the design and sequencing.

What was reviewed:

  • Counter balance: latch sites (both peeked branches) vs. clear sites (close_raw, detach, resume, sweep_timer_cb) — no double-decrement (sweep clears the flag before calling close_raw); us_socket_adopt's memcpy preserves the flag on the relocated socket so the count stays consistent.
  • The four init sites cover every us_socket_t birth; fin_deferred is an unconditional struct field so the un-#ifdef'd inits compile on non-libuv backends.
  • Allowlist mirrors us_internal_send_errno_is_peer_gone minus EPIPE (no WSA equivalent for send-side); WSAESHUTDOWN correctly falls to default: return 0.
Extended reasoning...

Overview

This PR fixes a Windows-only socket strand: a paused socket whose peer sends a clean FIN on an empty receive buffer, then later dies, never gets a close callback. It touches four C files in packages/bun-usockets/ (the libuv backend's fin_deferred sweep) plus three Windows-gated tests in test/js/bun/net/socket.test.ts. The changes are: (1) initialize s->fin_deferred = 0 at all four socket birth sites, (2) latch fin_deferred in the peeked == 0 branch of poll_cb (previously only peeked > 0 latched), (3) clear the flag in us_socket_detach since detach bypasses close_raw, and (4) tighten the peer-reset probe from a denylist to an allowlist of WSA peer-gone codes.

Security risks

None identified. This is internal socket lifecycle bookkeeping with no user-controlled input reaching the changed code paths; the probe's allowlist change is strictly more conservative (fewer conditions treated as peer-gone).

Level of scrutiny

High. This is low-level C networking code on a platform-specific event path with a manually balanced counter invariant (fin_deferred_count) whose correctness depends on every latch site being paired with exactly one clear site across close/detach/resume/adopt. A miscount either disables the sweep (negative) or makes it walk every group every 4s forever (leaked positive). The PR description is thorough and the counter balance checks out on inspection, but the interaction with us_socket_adopt (memcpy of the bitfield onto the relocated socket while the old one is closed-listed without going through close_raw) and with the two overlapping open PRs (#34487 latches from the readable_ended state, #37077 proposes a competing UV_PRIORITIZED design) is subtle enough that a maintainer familiar with the AFD/DISCONNECT machinery should confirm.

Other factors

The three new tests are Windows-only and use structural sleeps (a 4.6s wait to span one sweep period, 12s stranded-detection races) — the comments justify why no observable signal exists, but they add ~20s to the Windows lane. The duplicate-PR bot flagged design overlap with #34487 and #37077 on the same poll_cb UV_DISCONNECT block; the author's response argues they compose, but a maintainer should decide the landing order. Given all of this, deferring to human review.

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