Skip to content

usockets: re-arm readable in raw_shutdown after read_eof so the close is delivered on Windows - #34487

Open
robobun wants to merge 4 commits into
mainfrom
farm/26049e5b/usockets-half-open-disconnect-spin
Open

usockets: re-arm readable in raw_shutdown after read_eof so the close is delivered on Windows#34487
robobun wants to merge 4 commits into
mainfrom
farm/26049e5b/usockets-half-open-disconnect-spin

Conversation

@robobun

@robobun robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • An allowHalfOpen socket whose peer has sent FIN strands forever on Windows if we call shutdown() after the poll has gone idle. Repro: Bun.listen({allowHalfOpen: true}), peer shutdown()s, victim gets end, victim calls shutdown() a few ticks later, victim's close never fires.
  • After usockets: stop spinning on a half-open socket whose peer resets behind pending writes (kqueue) #37077 latched read_eof, the half-open EOF path drops readable interest and, once pending writes drain, the poll settles with no events armed. us_internal_socket_raw_shutdown (packages/bun-usockets/src/socket.c) then masks the poll with events & READABLE, which is 0 & READABLE: a no-op us_poll_change, so nothing is armed to report that both halves are now closed.
  • epoll is unaffected (HUP is reported unmasked); kqueue is covered by the read sentinel usockets: stop spinning on a half-open socket whose peer resets behind pending writes (kqueue) #37077 added in the same function; the libuv backend has no equivalent, so only Windows strands.

Fix

  • In raw_shutdown, when read_eof is set, arm READABLE explicitly instead of masking. The next poll delivers the EOF against a SHUT_DOWN socket and the existing branch closes it.
  • Correct because after read_eof a readable wakeup can only ever report EOF (there is no more data), and the SHUT_DOWN eof branch is exactly the "both halves closed" close path every backend already uses; this just guarantees it has an event to run on.
  • Test: test/js/bun/net/socket.test.ts "closes when shutdown() runs after the poll has settled". On windows-x64 it times out against main and passes with this change; on Linux it passes both ways (HUP), as expected.
  • Also ran on windows-x64 and linux-x64 with the change: socket.test.ts, tcp-server.test.ts, node-net*.test.*, node-http-connect.test.ts, and the vendored test-net-*half*, test-http-*connect*, test-http-*upgrade*, test-net-half-open-peer-reset-* suites. No changes versus main.

Background

  • A half-open socket is one where the peer has closed its write side (we saw EOF) but we may still write. usockets tracks "EOF was delivered" in read_eof and stops polling for reads so the EOF is not re-reported.
  • A usockets poll is the set of readiness events (READABLE/WRITABLE) registered with the OS for a socket. On libuv this maps to uv_poll_start; registering zero events means the OS will not wake us for that socket at all.
  • raw_shutdown marks the socket SHUT_DOWN and sends our FIN. The loop closes a SHUT_DOWN socket when it next observes EOF on it; that observation needs a readable wakeup.

History: this PR originally also fixed the Windows drain storm on these sockets; #37077 landed that part on main, so this is rebased onto it and reduced to the remaining raw_shutdown gap.


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

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

Half-open socket shutdown

Layer / File(s) Summary
EOF polling state
packages/bun-usockets/src/socket.c
Socket creation initializes fin_deferred and read_eof. Writable and resume paths avoid restoring readable polling after EOF.
Shutdown EOF completion
packages/bun-usockets/src/socket.c, test/js/bun/net/socket.test.ts
Raw shutdown re-enables readable polling after EOF. The regression test verifies that an allowHalfOpen socket closes after shutdown and that the peer observes closure.

Possibly related PRs

  • oven-sh/bun#37920: Modifies related usockets EOF and half-open shutdown behavior through a different code path.

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 and concisely identifies the usockets fix and the Windows close-delivery issue.
Description check ✅ Passed The description explains the problem, fix, technical cause, regression test, and verification across Windows and Linux.

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

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/js/node/http/node-http-connect.test.ts:2 — The isWindows import added here is never used anywhere in the file — likely a leftover from an earlier draft that gated the new test on platform. Drop it from the import list.

    Extended reasoning...

    What the bug is

    The diff changes the harness import at line 2 of test/js/node/http/node-http-connect.test.ts from:

    import { bunEnv, bunExe, nodeExe } from "harness";

    to:

    import { bunEnv, bunExe, isWindows, nodeExe } from "harness";

    but isWindows is never referenced anywhere in the file body. Verified with grep against the full modified file — the only occurrence of the token isWindows is on line 2 (the import itself).

    Step-by-step proof

    1. Before this PR, the import was { bunEnv, bunExe, nodeExe }.
    2. This PR adds isWindows to that destructuring list.
    3. The new test "half-open CONNECT socket's poll settles after peer FIN (no drain/end storm)" runs unconditionally on all platforms — there is no test.skipIf(isWindows), no if (isWindows) branch, and no other reference to isWindows anywhere in the ~780-line file.
    4. Therefore the identifier is bound at module scope and never read — dead code introduced by this PR.

    The PR description notes the fix is Windows-specific and the fail-before proof is Windows-only, so it's plausible an earlier draft gated the test with test.skipIf(!isWindows) or similar, and the import survived when that gate was removed (the test was made cross-platform since endCount == 1 is a valid assertion on POSIX too).

    Why existing code doesn't prevent it

    TypeScript's noUnusedLocals does not flag unused named imports (they're module bindings, not locals), and Bun's test runner doesn't lint imports at runtime — so nothing in the toolchain currently catches this.

    Impact

    None at runtime — harness is already imported for bunEnv/bunExe/nodeExe, so there's no extra module load, and an unused binding has no observable effect. This is purely a code-cleanliness issue.

    REVIEW.md is explicit on the standard: "Every line you add must be demonstrably live" and "Delete dead code in the same PR that makes it dead." An import added by the PR that nothing consumes fails the first check.

    How to fix

    Remove isWindows from the import list:

    import { bunEnv, bunExe, nodeExe } from "harness";

    (i.e., revert line 2 to what it was before this PR).

Comment thread test/js/node/http/node-http-connect.test.ts Outdated
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 61c11e3: us_internal_rearm_writable and us_socket_resume now also consult readable_ended (a short write or resume on a FIN'd socket no longer re-arms READABLE at all), and a second test covers the backpressured-write-after-FIN path. Re-verified on windows-x64: both new tests pass, full node-http-connect/node-net/socket suites and the vendored node connect/upgrade/half-open tests unchanged.

@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/node/http/node-http-connect.test.ts`:
- Around line 630-632: Update the write fixture around socket.write in the
parent and corresponding child-process paths to capture the boolean backpressure
result from the 4 MiB write, assert that it is false, and thereby verify the
short-write/re-arm path is exercised. Preserve the existing write payload and
test flow while applying the same assertion to all referenced write sites.
🪄 Autofix (Beta)

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: 5253f74d-b28b-4c65-8cd4-d89b130a93c0

📥 Commits

Reviewing files that changed from the base of the PR and between c8dde4c and 61c11e3.

📒 Files selected for processing (3)
  • packages/bun-usockets/src/loop.c
  • packages/bun-usockets/src/socket.c
  • test/js/node/http/node-http-connect.test.ts

Comment thread test/js/node/http/node-http-connect.test.ts Outdated
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Aug 12th, 2026

@robobun, your commit ff1251a has 2 failures in Build #93619 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34487

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

bun-34487 --bun

@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/node/http/node-http-connect.test.ts`:
- Around line 631-635: Update the parent result handling around the
backpressured fixture to destructure the recorded backpressured value alongside
endCount, and add a non-restrictive assertion that it is a boolean (for example,
using the existing assertion style with expect.any(Boolean)). Preserve endCount
=== 1 as the behavioral invariant.
🪄 Autofix (Beta)

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: 2f45eb5d-f2ab-444d-9e38-bbcf1f8998f9

📥 Commits

Reviewing files that changed from the base of the PR and between 61c11e3 and 1c41438.

📒 Files selected for processing (1)
  • test/js/node/http/node-http-connect.test.ts

Comment thread test/js/node/http/node-http-connect.test.ts Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/node/http/node-http-connect.test.ts (1)

607-610: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that the expected drain path occurs

The test currently passes with drainCount === 0, so it would not detect an implementation that suppresses all writable events instead of only preventing repeated events. Assert at least one drain while retaining the upper bound.

Proposed fix
     expect(drainCount).toBeLessThanOrEqual(3);
+    expect(drainCount).toBeGreaterThanOrEqual(1);
🤖 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/node/http/node-http-connect.test.ts` around lines 607 - 610, Update
the drainCount assertion in the half-open writable-event test to require at
least one drain while preserving the existing upper bound of three, ensuring the
expected drain path occurs without allowing repeated events.

Source: Coding guidelines

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

Outside diff comments:
In `@test/js/node/http/node-http-connect.test.ts`:
- Around line 607-610: Update the drainCount assertion in the half-open
writable-event test to require at least one drain while preserving the existing
upper bound of three, ensuring the expected drain path occurs without allowing
repeated events.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ef89e1ab-4f45-48fa-bec4-281a885e343c

📥 Commits

Reviewing files that changed from the base of the PR and between 1c41438 and 4e22309.

📒 Files selected for processing (1)
  • test/js/node/http/node-http-connect.test.ts

@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Re drainCount >= 1: declining. Node reports drainCount === 0 here (no prior backpressure, so no 'drain'); Bun's single drain is from the half-open path arming WRITABLE once, which is pre-existing behavior this PR does not introduce. Locking in >= 1 would assert a divergence from Node as an invariant. If a future change over-suppressed writable events after FIN, test-net-allow-half-open.js and node-net-allowHalfOpen.test.js (which assert data written after FIN is received) would catch it; both pass with this PR.

Comment thread packages/bun-usockets/src/socket.c Outdated
Comment thread test/js/node/http/node-http-connect.test.ts Outdated
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 74617 at 6019e49: 285/286 passed. The one red job is test-worker-message-port-transfer-terminate.js on debian x64-asan, which is pre-existing on main and unrelated to this diff. node-http-connect.test.ts (the new test) passed on every lane including all Windows lanes. Ready for review.

Comment thread packages/bun-usockets/src/eventing/libuv.c Outdated
Comment thread packages/bun-usockets/src/socket.c Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

404ab6f addresses both prior 🔴 threads: raw_shutdown now re-arms READABLE and lets the SHUT_DOWN branch close on the next poll (so onWritable sees a live socket after shutdown() returns — the synchronous-close_raw UAF path is gone), and the readable_ended DISCONNECT branch now probes for RST. The remaining inline comment is a follow-up on the latter: the probe only runs while poll_cb is still firing, so a post-settle RST still has no delivery path.

Extended reasoning...

404ab6f responded to both of my 15:26 comments. I re-traced us_internal_socket_raw_shutdown: with readable_ended it now does us_poll_change(READABLE) + bsd_shutdown_socket() and returns without dispatching on_close; the SHUT_DOWN → eof → close_raw path fires on the next iteration on all three backends (libuv via UV_DISCONNECT/readable, epoll via EPOLLHUP/recv=0, kqueue via EVFILT_READ recv=0). That restores the "shutdown never synchronously closes" contract the removed TODO documented, so the HttpContext::onWritable use-after-dtor concern no longer applies. The libuv.c change matches the probe I suggested; the new inline finding is the residual gap in that same area, not a new class.

Comment thread packages/bun-usockets/src/eventing/libuv.c Outdated
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

af206e7: hands readable_ended sockets to the existing fin_deferred sweep at settle, so a RST arriving after the poll has settled at events=0 is escalated via the sweep SO_ERROR/send-probe (mirroring the paused-socket sibling). Build 74623 at 404ab6f finished 285/286 with only the pre-existing shell-load.test.ts darwin flake red; node-http-connect.test.ts passed on every lane.

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

Beyond the inline findings on af206e7 × 404ab6f, I also checked whether us_socket_resume clearing fin_deferred for a readable_ended socket drops sweep RST coverage — it doesn't: resume re-arms WRITABLE, which ORs in UV_DISCONNECT, so a later RST reaches poll_cb's readable_ended probe directly.

Extended reasoning...

af206e7 addressed the previous round's post-settle-RST comment by handing settled readable_ended sockets to the fin_deferred sweep. This run examined the ownership-handoff sites for that new state and found raw_shutdown (inline finding). The adjacent us_socket_resume site was also examined — it already clears fin_deferred, and although a readable_ended socket then loses sweep coverage, resume's us_poll_change(WRITABLE) re-adds UV_DISCONNECT, so poll_cb fires again on a later RST and the in-branch probe catches it; no gap there.

Comment thread packages/bun-usockets/src/socket.c Outdated
Comment thread packages/bun-usockets/src/socket.c Outdated
Comment thread packages/bun-usockets/src/socket.c Outdated
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

a00469c is the final iteration for this PR. All sibling sites that interact with the new readable_ended/fin_deferred state are now consistent (loop.c guard, poll_cb probe + sweep enrollment, rearm_writable, resume, raw_shutdown). Any further findings in openssl.c or other files this PR does not touch are pre-existing and will go through separate handoffs.

CI: four consecutive builds (74617, 74623, 74631, 74640) with node-http-connect.test.ts green on every lane including all Windows lanes. The red jobs across those builds are pre-existing/unrelated (test-worker-message-port-transfer-terminate, test-http2-reset-flood, shell-load, and the borderline node-net.test.ts leak-threshold at delta=12 vs threshold=10, which has been intermittent on multiple lanes across every build here and is owned by another fix session). The us_socket_resume change at a00469c is inside #ifdef LIBUS_USE_LIBUV and is a byte-identical no-op on Linux.

Ready for review.

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

a00469c addresses the last nit (the fin_deferred clear in us_socket_resume now runs after the !is_paused early-return, so a settled readable_ended socket keeps sweep ownership). Nothing further from this pass; deferring final sign-off to a human given the subsystem.

Checked: the paused path that now proceeds past the early-return still self-heals — us_poll_change(WRITABLE) re-ORs UV_DISCONNECT, so poll_cb's readable_ended branch re-enrolls the socket in the sweep within ~2 iterations; and raw_shutdown (92e79f6) already clears fin_deferred for the SHUT_DOWN case, so no stale ownership there.

Extended reasoning...

Overview

This PR adds a readable_ended bit to us_socket_t and uses it to stop the Windows/libuv poll_cb from re-deriving EOF on every tick once a half-open socket has already dispatched on_end — the level-triggered AFD UV_DISCONNECT was re-arming WRITABLE|DISCONNECT and firing on_writable/on_end per uv_run iteration. It touches packages/bun-usockets/src/{context.c,loop.c,socket.c,eventing/libuv.c,internal/internal.h} plus a regression test in test/js/node/http/node-http-connect.test.ts. Over five follow-up commits it also: probes for RST when the settled poll would otherwise miss it, hands settled half-open sockets to the fin_deferred sweep, defers close to next iteration in raw_shutdown to avoid a synchronous ~HttpResponseData() UAF, and clears fin_deferred at the two ownership-handoff points (raw_shutdown, resume).

Security risks

None identified. This is event-loop poll-mask/lifecycle bookkeeping; no user-controlled input parsing, no auth/crypto surface. The main risk class was memory safety (the synchronous-close UAF found and fixed in 404ab6f) and lifecycle correctness (sweep ownership vs. WSAESHUTDOWN misclassification, fixed in 92e79f6).

Level of scrutiny

High. This is core usockets eventing across three backends (epoll/kqueue/libuv-AFD) with subtle differences in level-triggered vs. one-shot vs. unmaskable semantics, and it interacts with the fin_deferred sweep, half-open close sequencing, and uWS onWritable re-entrancy. The PR went through five substantive review iterations, each of which found a real issue that required a fix commit. That history alone argues for a human maintainer's final look, and the load-bearing behavior is Windows-only so only the Windows CI lanes exercise it.

Other factors

  • a00469c implements exactly the fix suggested in my last nit (move the clear after the early-return) and updates the comment. I traced the remaining paused+readable_ended interaction: it self-heals via the WRITABLE re-arm re-ORing UV_DISCONNECT, and the SHUT_DOWN case can't carry a stale fin_deferred because 92e79f6 clears it in raw_shutdown.
  • The bug-hunting pass on this head found nothing new.
  • The one open item deliberately left out of scope (SSL sibling of the raw_shutdown fix in us_internal_ssl_shutdown) is pre-existing on POSIX and the author has filed it separately; I agree that's reasonable scoping.
  • The new test asserts stderr === "", which REVIEW.md discourages for ASAN/debug noise, but the file already spawns with bunEnv and other tests in the suite do similar; not raising it.
  • Not approving: this is not a simple/mechanical change, it's Windows-AFD-specific socket lifecycle logic in a vendored C library with cross-platform reach, and it took multiple rounds to converge. A maintainer should sign off.

Jarred-Sumner pushed a commit that referenced this pull request Jul 17, 2026
… a half-open socket (#34498)

### What does this PR do?

#### Problem

`us_internal_ssl_shutdown` only takes the `SSL_shutdown()` path when
`SSL_RECEIVED_SHUTDOWN` is already set (the `!SSL_in_init &&
!RECEIVED_SHUTDOWN` branch at the top handles the common case with a raw
TCP half-close instead). That precondition is met for an
`allow_half_open` TLS server socket whose peer has already half-closed:
`ssl_on_end` sets `SSL_RECEIVED_SHUTDOWN` before dispatching `'end'`,
and the ZERO_RETURN path sets it by receiving the peer's close_notify.

With `RECEIVED_SHUTDOWN` already set, `SSL_shutdown()` sends our
close_notify and returns 1. The function then returned without calling
`us_internal_socket_raw_shutdown`, so:

- no TCP FIN was ever sent to the peer (only the close_notify TLS
record);
- the poll type stayed `SOCKET`, not `SHUT_DOWN`;
- on epoll, the half-open poll had already been changed to `WRITABLE`
when the peer's FIN was handled, the writable dispatch drops it to 0
once drained, and `EPOLLHUP` (the only thing reported at events=0) needs
both halves FIN'd, so the loop's `is_shut_down` close path never ran.

Every other exit from `us_internal_ssl_shutdown` already calls
`us_internal_socket_raw_shutdown`; only the `ret >= 0` path did not.

#### Reachable path

The only sockets that reach the `SSL_shutdown()` path after a peer
half-close are `UWS_HTTP_TLS` sockets with `allow_half_open` set:
`node:https` server CONNECT/Upgrade tunnels (`upgradeToTunnelMode` sets
`allow_half_open`), where the JS `'end'` handler's `socket.end()` routes
through `handle.end()` → `us_socket_shutdown`. `net.Socket`'s Duplex
`autoDestroy: true` normally follows with `handle.close()`, which hid
this; with `autoDestroy` disabled the socket never closes.

#### Fix

Call `us_internal_socket_raw_shutdown(s)` after the `SSL_shutdown`
branch regardless of `ret`, matching the other exit paths. This sends
the TCP FIN and moves the poll type to `SHUT_DOWN`.

#### Relation to #34487

Once the FIN is sent, epoll reports `EPOLLHUP` (both halves down) and
the socket closes via the `is_shut_down` eof branch. kqueue and libuv
still need the `readable_ended` re-arm from #34487 for the poll to
re-derive eof at events=0; this PR is the TLS-side prerequisite that
gets the FIN on the wire at all.

### How did you verify your code works?

New test in `test/js/node/http/node-http-connect.test.ts` spawns an
https CONNECT server with `autoDestroy` disabled, a TLS client that
half-closes first (raw FIN, no close_notify first), and waits for the
server socket's `'close'`:

```
# before
(fail) … https CONNECT socket.end() after peer FIN half-closes TCP so the socket can close
  ^ this test timed out after 5000ms.
# after
(pass) … https CONNECT socket.end() after peer FIN half-closes TCP so the socket can close
```

Gated to Linux because the close is observed via `EPOLLHUP`;
kqueue/libuv coverage comes with #34487.

Also ran with no new failures vs main: `test/js/node/tls/` (159/161),
`test/js/bun/net/socket.test.ts` + `test/js/node/net/` (249/268), and
`test/js/node/test/parallel/test-{tls,https}-*.js` (207/210).

<!-- robobun:evidence:begin -->

---

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

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase

@robobun
robobun force-pushed the farm/26049e5b/usockets-half-open-disconnect-spin branch from a00469c to 00d841d Compare July 18, 2026 01:39
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (78f3a46, picks up #34498). Conflict was in node-http-connect.test.ts where #34498 added its https CONNECT test at the same spot; kept both. Branch at 00d841d.

CI build 74979 finished 284/286. node-http-connect.test.ts green on every lane including all Windows lanes (fifth consecutive build). The two red jobs are both on debian x64-asan and unrelated: test-worker-message-port-transfer-terminate.js (pre-existing on main) and vendor/elysia/test/response/stream.test.ts (Bun.serve request-cancellation race; not allow_half_open so readable_ended never applies). Both are tracked separately.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five prior rounds of findings are addressed and this pass found nothing new, but this is subtle enough (Windows AFD level-triggered DISCONNECT, fin_deferred sweep ownership across settle/shutdown/resume, and the raw_shutdown re-arm now affecting kqueue too) that a maintainer should sign off.

Checked this pass: the reordered !is_paused early-return in us_socket_resume — a paused+readable_ended socket that resumes self-heals via us_poll_change(WRITABLE) re-ORing UV_DISCONNECT and re-enrolling in the sweep on the next settle; the paused+readable_ended+shut_down kqueue case is effectively unreachable (readable_ended is only set while not paused, and raw_shutdown's READABLE arm delivers the close before a same-tick pause/resume could intervene). Also confirmed readable_ended/fin_deferred are zeroed at every socket creation site (accept, connect, listen, from_fd, start_connections) and that the new bit sits in the existing pad so us_socket_t doesn't grow.

Extended reasoning...

Overview

Fixes a Windows-only busy-spin where an allow_half_open usockets socket's uv_poll_t re-arms WRITABLE|UV_DISCONNECT on every tick after peer FIN, flooding on_writable/'drain'. Adds a readable_ended bit to us_socket_t, guards the half-open EOF re-entry in loop.c, and teaches libuv.c poll_cb to stop mapping level-triggered UV_DISCONNECT back to a readable dispatch once on_end has fired. Follow-on commits (all in this PR) address interactions the initial fix exposed: RST-after-FIN detection via probe + fin_deferred sweep enrollment, raw_shutdown arming READABLE instead of closing synchronously (avoids UAF in HttpContext::onWritable), clearing fin_deferred in raw_shutdown so the sweep's send-probe doesn't misread WSAESHUTDOWN as a reset, and reordering us_socket_resume's fin_deferred clear after the !is_paused early-return.

Files: internal.h (struct bit), context.c/loop.c/socket.c (init sites + guards), eventing/libuv.c (poll_cb readable_ended branch), and a new spawned-fixture test in node-http-connect.test.ts.

Security risks

None. No user-controlled input parsing, no auth/crypto changes. The change alters when poll events re-arm and when the fin_deferred sweep owns a socket; the risk class is lifetime/ordering (UAF, missed close, spurious RST), not security.

Level of scrutiny

High. packages/bun-usockets is the foundation of every TCP socket in Bun; a mistake here affects all HTTP/net/TLS on the affected platform. The fix reasons about Windows AFD polling semantics (level-triggered DISCONNECT, AFD_POLL_ABORT subscription rules from Bun's libuv patches), kqueue's lack of unmaskable HUP, and epoll's unmaskable EPOLLERR/EPOLLHUP — three different eventing models that must converge on the same observable behavior. The PR went through five review iterations, each finding a real interaction bug (synchronous-close UAF, post-settle RST gap, WSAESHUTDOWN misread, resume() ordering), which is itself a signal that the state machine is delicate.

Other factors

  • CI green on all lanes including Windows across four consecutive builds; the new test passed on every Windows lane and the fail-before was verified locally on windows-x64 (drainCount 1707→1).
  • The SSL sibling (us_internal_ssl_shutdown returning without raw_shutdown when SSL_RECEIVED_SHUTDOWN is set) was explicitly deferred as pre-existing on epoll/kqueue and out of scope; that's reasonable but worth a maintainer's nod.
  • The 30s per-test timeout on "tests should run on bun" is justified (child bun-debug spawns seven subtests; ~2s startup under ASAN) — it's not masking a hang in the code under test.
  • Jarred is already engaged (requested the rebase), so human review is already in flight.

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up on overlap with #34478: that PR's us_loop_pump parity fix (poll IOCP for unref'd handles) needs this bounce to be fixed to not regress test-http-server-unconsume-consume.js, so it includes a narrower variant in poll_cb (using !(poll_type & POLL_TYPE_POLLING_IN) as the discriminator, no new flag). This PR's readable_ended approach is more thorough (covers us_internal_rearm_writable and the loop.c arm too) and supersedes that if both land. Either can go first; #34478's new CONNECT-tunnel test in node-http-connect.test.ts asserts end fires once, which both approaches satisfy.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

This fix also covers a POSIX-reproducible path that the PR body does not mention: a partial write issued from inside the end handler calls us_internal_rearm_writable, which on main re-arms READABLE on an fd whose read side has already EOF'd, so the next epoll iteration delivers another 0-byte read and on_end fires again. The readable_ended check this PR adds to us_internal_rearm_writable (and the loop.c guard) stops that.

Linux x64, release bun on main:

const server = Bun.listen({
  hostname: "127.0.0.1", port: 0, allowHalfOpen: true,
  socket: { open(s) { s.shutdown(); }, data(){}, end(s){ s.end(); server.stop(); }, close(){} },
});
let endCount = 0;
await Bun.connect({
  hostname: "127.0.0.1", port: server.port, allowHalfOpen: true,
  socket: {
    data(){}, drain(){}, close(){},
    end(s) {
      endCount++;
      if (endCount === 1) s.write(Buffer.alloc(4 * 1024 * 1024));
    },
  },
});
setTimeout(() => { console.log("end fired", endCount, "times"); process.exit(endCount === 1 ? 0 : 1); }, 500);

Prints end fired 2 times on main (more under a debug build), end fired 1 times with this branch built locally. That gives a fail-before-provable test on Linux, which the current node-http-connect.test.ts addition does not (as the body notes). Something like this in test/js/bun/net/socket.test.ts would cover it:

test("allowHalfOpen: end fires once when the handler backpressures a write", async () => {
  await using proc = Bun.spawn({
    cmd: [bunExe(), "-e", `
      const server = Bun.listen({
        hostname: "127.0.0.1", port: 0, allowHalfOpen: true,
        socket: { open(s){ s.shutdown(); }, data(){}, end(s){ s.end(); server.stop(); }, close(){} },
      });
      let endCount = 0;
      const { promise, resolve } = Promise.withResolvers();
      await Bun.connect({
        hostname: "127.0.0.1", port: server.port, allowHalfOpen: true,
        socket: {
          data(){}, drain(){},
          end(s){ if (++endCount === 1) s.write(Buffer.alloc(4 * 1024 * 1024)); },
          close(){ resolve(); },
        },
      });
      await promise;
      console.log(JSON.stringify({ endCount }));
    `],
    env: bunEnv, stdout: "pipe", stderr: "pipe",
  });
  const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
  expect(stderr).toBe("");
  expect(JSON.parse(stdout.trim())).toEqual({ endCount: 1 });
  expect(exitCode).toBe(0);
});

Comment thread packages/bun-usockets/src/eventing/libuv.c Outdated
Comment thread packages/bun-usockets/src/socket.c Outdated
Comment thread test/js/node/http/node-http-connect.test.ts Outdated
Comment thread test/js/node/http/node-http-connect.test.ts Outdated
Comment thread test/js/node/http/node-http-connect.test.ts Outdated
@cirospaciari

Copy link
Copy Markdown
Member

@robobun fix conflicts adopt and fix comments

… is delivered

After the peer's FIN on an allow_half_open socket (read_eof), the poll
drops readable interest and settles with no events once writes drain.
A later shutdown() then masked against those zero events, so nothing
was armed to deliver the now fully-closed state and the socket stranded
on the libuv backend (epoll reports HUP unmasked; kqueue already arms a
sentinel here). Arm READABLE explicitly in that state so the SHUT_DOWN
eof branch closes the socket on the next poll.
@robobun
robobun force-pushed the farm/26049e5b/usockets-half-open-disconnect-spin branch from 00d841d to d3f367f Compare August 12, 2026 20:27
@robobun robobun changed the title usockets(win): stop half-open socket's poll re-arming WRITABLE on level-triggered DISCONNECT usockets: re-arm readable in raw_shutdown after read_eof so the close is delivered on Windows Aug 12, 2026
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and reduced, per review.

While resolving conflicts it turned out #37077 had landed the bulk of this PR on main: the read_eof bit, the loop.c guard, the rearm_writable/resume gating, and a libuv.c DISCONNECT branch. I verified on windows-x64 that current main no longer reproduces the drain storm (drainCount is 1), so all of that is dropped here and main's naming is adopted.

What main still lacks is the raw_shutdown case: an allowHalfOpen socket whose peer has FIN'd and whose poll has gone idle strands forever on Windows if we shutdown() it afterwards. This PR is now just that, a 3-line change in us_internal_socket_raw_shutdown plus an in-process test in test/js/bun/net/socket.test.ts (times out on main on windows-x64, passes with the change; passes both ways on Linux since epoll reports HUP unmasked).

Review items: long comments removed or cut to one line, the inline fixture and Bun.spawn are gone (the test is in-process now), and the timeout bump is reverted. Title and description updated to match the new diff.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Note

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is d3f367f (fix) + 9dfa9ee (test) + ff1251a (comment); all review threads are resolved.

CI on 93619: final result: 145 jobs passed, including windows x64 build-bun and all 8 windows 2019 x64 test-bun shards, so the new test (which times out on main on Windows) passed on the platform this fixes, and the Linux lanes that ran are green as well. The remaining red is unrelated to the change: four build-bun lanes (darwin x64, linux aarch64-musl, linux aarch64-android, windows aarch64) failed downloading vendored dependencies with HTTP 503 from github.com before compiling anything, which also took their downstream test shards with them, and 2 further jobs expired waiting for an agent (the same outage failed builds 93555, 93558 and 93581 outright and is affecting main); the listed test failures are all flaky entries that passed on retry or when run alone, none of them socket-related. I am not pushing further retriggers; the affected lanes will be covered by a re-run once GitHub recovers or by the merge queue.

Local verification for the record: on windows-x64 the test times out 3/3 against main and passes 3/3 with the fix; socket.test.ts, tcp-server, node-net*, node-http-connect and the vendored half-open/CONNECT/upgrade suites match main on windows-x64 and linux-x64.

@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 3867-3868: Replace the fixed five-iteration setImmediate loop in
the socket shutdown test with an awaited observable condition that confirms
readable interest has been removed and the poll has reached its required idle
state. Invoke shutdown only after that callback or state signal is received,
preserving the existing test flow without arbitrary delays.
🪄 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: 07beab16-6380-48ce-971f-4150128f2391

📥 Commits

Reviewing files that changed from the base of the PR and between 315136d and 7c822d2.

📒 Files selected for processing (2)
  • packages/bun-usockets/src/socket.c
  • test/js/bun/net/socket.test.ts

Comment thread test/js/bun/net/socket.test.ts Outdated
Comment thread test/js/bun/net/socket.test.ts Outdated
Comment thread test/js/bun/net/socket.test.ts Outdated
…mises on error

Wait for the post-EOF drain event (the last thing the socket emits) plus
the two loop turns needed for the poll phase to consume the FIN's final
re-report, instead of an unexplained number of turns. Awaiting drain alone
is too early: shutdown() then still finds a wakeup armed and the test
passes without the fix. Wire error handlers to reject and drop the dead
peer.end().
Comment thread packages/bun-usockets/src/socket.c
Comment thread packages/bun-usockets/src/socket.c
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.

3 participants