Skip to content

usockets(win): defer READABLE re-arm past on_open so .end() mid-handshake doesn't hang - #30028

Open
Jarred-Sumner wants to merge 5 commits into
mainfrom
claude/win-tls-end-mid-handshake
Open

usockets(win): defer READABLE re-arm past on_open so .end() mid-handshake doesn't hang#30028
Jarred-Sumner wants to merge 5 commits into
mainfrom
claude/win-tls-end-mid-handshake

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented May 1, 2026

Copy link
Copy Markdown
Collaborator

.end() on a Bun.connect TLS socket from inside its open handler (which fires pre-handshake when a handshake handler is present) hung the process on Windows. Surfaced by #30024's new test.

Root cause

us_poll_stop (libuv backend) called uv_close on the uv_poll_t immediately. When open runs inside the connect-WRITABLE poll_cb, the sequence is uv_poll_start(READABLE) (from us_poll_change at context.c:732) → JS openend()us_internal_socket_close_rawus_poll_stopuv_close, all in the same poll_cb frame. uv_close on a handle whose AFD poll request was just submitted in that frame wedges the handle (close_cb never fires). POSIX has no analogue (epoll_ctl(DEL)/kqueue mask change).

Fix

Move uv_close from us_poll_stop to us_poll_free. Every us_poll_stop caller parks the socket on closed_head/closed_udp_head; us_internal_free_closed_sockets drains those from check_cb (a uv_check_t handler — runs after I/O callbacks, outside poll_cb). uv_close from there completes normally on the next tick. us_internal_socket_after_open stays bit-identical to POSIX.

Regression test in socket.test.ts.

@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator
Updated 5:10 AM PT - May 1st, 2026

@Jarred-Sumner, your commit 4c4f36f has 10 failures in Build #49641 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30028

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

bun-30028 --bun

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4409eabe-f9fb-4291-9670-16432f0be784

📥 Commits

Reviewing files that changed from the base of the PR and between 1c3cbf9 and 4c4f36f.

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

Walkthrough

Adds a TLS socket regression test that exercises calling end() from a socket open handler and verifies precise event sequencing. Refactors libuv poll lifecycle: us_poll_stop no longer closes/freeing handles; us_poll_free now performs uv_close and final free via a close callback.

Changes

Cohort / File(s) Summary
TLS socket test
test/js/bun/net/socket.test.ts
Adds a regression test creating a TLS server and client socket where the client's open handler immediately calls end(), captures a handshake callback with ok=false and ECONNRESET, waits for close, and asserts event order ["open","handshake:false:ECONNRESET","close"].
libuv poll lifecycle
packages/bun-usockets/src/eventing/libuv.c
Refactors poll shutdown: us_poll_stop now only stops polling (no longer clears uv_poll_t->data or calls uv_close). us_poll_free unconditionally rebinds p->uv_p->data = p, early-returns for already-closing handles, and calls uv_close with close_cb_free_poll so free occurs after uv_close completes. Comments updated to reflect the new control flow.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main Windows-specific fix: deferring READABLE re-arm past on_open to prevent hangs when .end() is called mid-handshake.
Description check ✅ Passed The description comprehensively covers the problem, root cause, fix, and includes a regression test, exceeding the minimal template requirements.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

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

Comment thread packages/bun-usockets/src/context.c Outdated
Comment thread packages/bun-usockets/src/context.c 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/bun-usockets/src/context.c`:
- Around line 776-779: The post-open re-arm always sets LIBUS_SOCKET_READABLE
which can re-enable reads if user code paused the socket in open; modify the
mask passed to us_poll_change so it includes LIBUS_SOCKET_READABLE only when
s->flags.is_paused is false (i.e., compute an event_mask that conditionally adds
LIBUS_SOCKET_READABLE based on s->flags.is_paused, still preserving the existing
writable logic that checks s->flags.last_write_failed) before calling
us_poll_change in the block guarded by us_socket_is_closed.
🪄 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: 8b08930c-eee9-402e-9554-6553c8c2f543

📥 Commits

Reviewing files that changed from the base of the PR and between 2dc4f5c and 212d7e8.

📒 Files selected for processing (1)
  • packages/bun-usockets/src/context.c

Comment thread packages/bun-usockets/src/context.c Outdated
Comment thread packages/bun-usockets/src/context.c Outdated
Replaces the context.c special-casing. Root cause is uv_close on a uv_poll_t
inside its own poll_cb (after us_poll_change just submitted a fresh AFD
request). All us_poll_stop callers park on closed_head and reach us_poll_free
from check_cb (outside poll_cb), so closing there avoids the re-entrancy
without changing socket_after_open semantics on any platform.
Comment thread packages/bun-usockets/src/eventing/libuv.c
Comment thread test/js/bun/net/socket.test.ts Outdated
Comment on lines 125 to 130
void us_poll_stop(struct us_poll_t *p, struct us_loop_t *loop) {
if(!p->uv_p) return;
uv_poll_stop(p->uv_p);

/* We normally only want to close the poll here, not free it. But if we stop
* it, then quickly "free" it with us_poll_free, we postpone the actual
* freeing to close_cb_free_poll whenever it triggers. That's why we set data
* to null here, so that us_poll_free can reset it if needed */
p->uv_p->data = 0;
uv_close((uv_handle_t *)p->uv_p, close_cb_free_poll);
/* uv_close deferred to us_poll_free — see comment there for why closing
* here (inside poll_cb) wedged the handle on Windows. */
}

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.

🔴 Windows CI is red on 1c3cbf9: 7 node:http parallel tests (test-http-{flush-response-headers,client-timeout-option,client-timeout-event,response-close,client-timeout,agent-remove,client-finished}.js) time out on all three Windows targets — that's a systematic regression in the normal close path, not flake. Deferring uv_close to us_poll_free inverts the order at every close site (e.g. socket.c:289→298): closesocket(fd) now runs before uv_close on the uv_poll_t, whereas pre-PR uv_close ran synchronously inside us_poll_stop before closesocket. This needs root-causing before merge — right now the PR trades one Windows hang for several others.

Extended reasoning...

What the bug is

robobun's CI report for commit 1c3cbf9 (the current libuv.c approach in this PR — 4c4f36f is docs-only) shows 7 distinct node:http parallel tests timing out on all three Windows targets (2019 x64, 2019 x64-baseline, 11 aarch64): test-http-flush-response-headers, test-http-client-timeout-option, test-http-client-timeout-event, test-http-response-close, test-http-client-timeout, test-http-agent-remove, test-http-client-finished. ~20 timeout occurrences clustered in one functional area across every Windows build is not flakiness — it's a deterministic regression in the everyday socket-close path. The lone macOS no-orphans timeout is unrelated noise.

The code path

Moving uv_close from us_poll_stop to us_poll_free flips the relative order of uv_close and closesocket() at every close site:

  • socket.c:289us_poll_stopsocket.c:298bsd_close_socket(fd) … later, check_cbus_internal_free_closed_socketsus_poll_freeuv_close.
  • Same pattern at socket.c:198→199, context.c:411→412, udp.c:105→106.

Pre-PR, us_poll_stop did uv_poll_stop then uv_close synchronously, so by the time bsd_close_socket/closesocket ran the handle was already UV_CLOSING and libuv's uv__poll_close had cancelled the AFD poll request against a still-live SOCKET. Post-PR, the SOCKET is dead before libuv ever sees the close.

Why existing code doesn't prevent it

uv_poll_stop alone does not cancel the outstanding AFD poll request on Windows — it only clears the event mask and unrefs the handle; the AFD cancellation happens in uv__poll_close (reached via uv_close). Nothing else in this PR re-orders bsd_close_socket after us_poll_free, and the sweep that drains closed_head runs from check_cb strictly after the closesocket() at socket.c:298. So every normal HTTP request that closes its socket now hits closesocket-before-uv_close.

Step-by-step proof

  1. A node:http test opens a client connection, gets a response, and closes (socket.destroy() / req.abort() / agent free).
  2. That reaches us_socket_close → socket.c:289 us_poll_stop(p). Post-PR this is just uv_poll_stop(p->uv_p) — handle is stopped, not closing; the AFD poll request submitted by the last uv_poll_start is still pending in the kernel.
  3. socket.c:298 bsd_close_socket(fd)closesocket(). The underlying SOCKET is gone.
  4. The socket is parked on closed_head. On the next check_cb tick, us_internal_free_closed_sockets calls us_poll_freeuv_close(p->uv_p, close_cb_free_poll).
  5. libuv's uv__poll_close tries to cancel the pending AFD request via the (now-dead) socket / peer socket. If that cancellation can't complete, reqs_pending never drops to 0, uv__want_endgame never queues the handle, close_cb_free_poll never fires, and the handle stays ref'd in the loop — process hangs → test timeout.

Whether the precise mechanism is exactly the AFD-cancel-on-dead-fd path or something adjacent in libuv's Windows poll endgame, the empirical evidence is unambiguous: the PR's stated validation strategy is "Windows CI is the validation here", and Windows CI is red on the exact commit with the exact symptom (hangs in socket close paths). The ordering inversion is the only semantic change at those sites.

Impact

This regresses the common close path — every node:http/fetch connection close on Windows — to fix the rare one (.end() inside open mid-handshake). Shipping this would hang real Windows workloads.

How to fix

Preserve the pre-PR ordering — uv_close must run before closesocket(). Two options:

  • Keep uv_close in us_poll_stop for the normal path and only defer when us_poll_stop is re-entered from inside the same handle's poll_cb (e.g. a per-loop "currently dispatching poll == p" sentinel that routes the in-frame case onto a deferred-close list drained from check_cb).
  • Or move bsd_close_socket(fd) into us_poll_free (after uv_close) so the fd outlives the AFD cancellation — but that's a larger invariant change touching fd-reuse and SO_LINGER paths.

Either way, this needs to be root-caused against the failing tests on a Windows box before merge.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Heads up: #33018, opened today from an independent investigation, overlaps with this PR. It makes the same change (uv_close moved from us_poll_stop to us_poll_free), arrived at from a different Windows failure: deterministic heap corruption (STATUS_HEAP_CORRUPTION, 0xC0000374) when a socket's data callback closes it and then synchronously re-enters the event loop. On top of that move, #33018 adds the tick_depth bracketing around uv_run that the libuv backend was missing relative to epoll_kqueue.c.

Two data points that may save you time, both measured on a Windows debug build:

The full relationship writeup is on #33018. Happy to rebase onto this one if it lands first, or fold the two into one however you prefer.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Two corrections to my comment above, and I think the second one is the reason this PR has been stuck.

1. I wrote that this PR's regression test passes on #33018's branch. That is no longer true. It was true of #33018's first revision, which included the same uv_close move this PR makes. I have since reverted that move entirely, so the two PRs no longer overlap and #33018 does not fix the .end()-mid-handshake hang. Sorry for the churn.

2. The uv_close move (from us_poll_stop to us_poll_free) hangs the node http timeout tests on Windows, and I believe that is why this PR's CI has been red since May 1. Build 49641's error annotations are test-http-client-timeout.js, test-http-client-timeout-event.js, test-http-client-timeout-option.js, test-http-client-finished.js, test-http-agent-remove.js, test-http-response-close.js, test-http-flush-response-headers.js, all on the Windows lanes. #33018's first revision got the same set. I reproduced test-http-client-set-timeout.js hanging deterministically (100% CPU spin) on a Windows debug build with the uv_close move applied, and instrumented libuv.c to get the trace.

The mechanism: us_poll_stop's uv_close is load-bearing for uv__loop_alive().

  • uv_close -> uv__handle_closing -> uv__active_handle_add raises loop->active_handles even for an unref'd handle, and all of bun's socket polls are unref'd (us_poll_start does uv_unref; the Async.KeepAlive comment there).
  • That active_handles++ is the only thing that makes uv_run execute another iteration after a socket closes.
  • Another iteration is the only thing that runs the uv_check_t -> us_internal_loop_post -> us_internal_free_closed_sockets sweep.
  • And after this PR's change, that sweep is the only thing that calls uv_close.

So a socket closed from the timer phase, which runs after the check phase (test-http-client-set-timeout.js's Agent({keepAlive: true, timeout: 50}) destroys the request from the timeout callback, which fires there), drops active_handles to zero, and the very next uv_run(UV_RUN_ONCE) returns at while (r != 0) without running a single phase. The sweep never runs again, closed_head never drains, other still-open sockets' IOCP completions are never even dequeued, and bun's JS-side liveness spins on uv_run(UV_RUN_NOWAIT) returning 0 forever.

The instrumented trace is 14 lines and unambiguous: three [NEW]s (listener, client, accepted), the check phase runs with closed_head empty, then the two [STOP]s fire from the timer callback after the check phase, and then nothing: no further [PRE]/[CHK], no us_poll_free, ever.

For the .end()-mid-handshake wedge this PR fixes, the underlying problem is real: the uv_close races an AFD request that us_poll_change(READABLE) submitted in the same poll_cb frame. But the fix has to preserve the uv_close's active_handles contribution. One shape that might work is keeping the uv_close in us_poll_stop and changing only how uv__poll_close cancels the in-frame request (for example, issuing the cancel before us_internal_socket_after_open's us_poll_change re-arms, or deferring just the uv__msafd_poll cancel). I haven't tried it. Happy to help if useful; #33018 is now fully independent of this one.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants