Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 23 additions & 21 deletions packages/bun-usockets/src/eventing/libuv.c
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@

#ifdef LIBUS_USE_LIBUV

/* uv_poll_t->data always (except for most times after calling us_poll_stop)
* points to the us_poll_t */
/* uv_poll_t->data points to the owning us_poll_t for the handle's whole
* lifetime (set in us_create_poll, cleared only by close_cb_free_poll). */
static void poll_cb(uv_poll_t *p, int status, int events) {
us_internal_dispatch_ready_poll((struct us_poll_t *)p->data, status < 0 && status != UV_EOF, status == UV_EOF,
events);
Expand All @@ -44,8 +44,10 @@

/* This one is different for polls, since we need two frees here */
static void close_cb_free_poll(uv_handle_t *h) {
/* It is only in case we called us_poll_stop then quickly us_poll_free that we
* enter this. Most of the time, actual freeing is done by us_poll_free. */
/* h->data is the owning us_poll_t (the embedding us_socket_t allocation).
* us_poll_free arms this; freeing here (after uv_close completes) is the
* only path — the synchronous-free branch was removed when uv_close moved
* out of us_poll_stop. */
if (h->data) {
free(h->data);
free(h);
Expand Down Expand Up @@ -76,18 +78,23 @@
free(p);
return;
}
/* The idea here is like so; in us_poll_stop we call uv_close after setting
* data of uv-poll to 0. This means that in close_cb_free we call free on 0
* with does nothing, since us_poll_stop should not really free the poll.
* HOWEVER, if we then call us_poll_free while still closing the uv-poll, we
* simply change back the data to point to our structure so that we actually
* do free it like we should. */
/* uv_close lives here (not in us_poll_stop) so it runs from check_cb via
* us_internal_free_closed_sockets — i.e. *outside* poll_cb. us_poll_stop is
* reachable re-entrantly inside the handle's own poll_cb (connect-WRITABLE →
* us_internal_socket_after_open → us_poll_change/uv_poll_start(READABLE) →
* on_open → JS end() → us_internal_socket_close_raw → us_poll_stop), and
* uv_close-ing a uv_poll_t whose AFD poll request was submitted in the same
* frame leaves the handle wedged (close_cb never fires; observed as a
* full-process hang on Windows for `Bun.connect` TLS sockets ended from
* `open`). Deferring to here keeps every us_poll_stop caller's semantics
* (they all park on closed_head/closed_udp_head and reach this), just one
* loop tick later. */
p->uv_p->data = p;
if (uv_is_closing((uv_handle_t *)p->uv_p)) {
p->uv_p->data = p;
} else {
free(p->uv_p);
free(p);
/* Shouldn't happen anymore, but tolerate a caller that already closed. */
return;
}
uv_close((uv_handle_t *)p->uv_p, close_cb_free_poll);
}

void us_poll_start(struct us_poll_t *p, struct us_loop_t *loop, int events) {
Expand Down Expand Up @@ -115,17 +122,12 @@
}
}

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. */
Comment thread
claude[bot] marked this conversation as resolved.
}

Check failure on line 130 in packages/bun-usockets/src/eventing/libuv.c

View check run for this annotation

Claude / Claude Code Review

Windows CI: 7 node:http tests time out on all Windows targets after 1c3cbf9

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 `
Comment on lines 125 to 130

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.


int us_poll_events(struct us_poll_t *p) {
return ((p->poll_type & POLL_TYPE_POLLING_IN) ? LIBUS_SOCKET_READABLE : 0) |
Expand Down
48 changes: 48 additions & 0 deletions test/js/bun/net/socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,3 +831,51 @@ it("reading fd of a TLS listener should not crash", () => {
expect(typeof listener.fd).toBe("number");
expect(listener.fd).toBeGreaterThanOrEqual(0);
});

// Regression: on Windows (libuv), `.end()` from `open` on a TLS socket — which
// fires on TCP-connect (pre-handshake) when a `handshake` handler is also
// present — left a uv_poll_t with a freshly-submitted AFD request racing
// uv_close in the same poll_cb frame. The handle never finished closing →
// process hung. The fix moves uv_close from us_poll_stop to us_poll_free,
// which runs from check_cb (outside poll_cb).
it("TLS .end() inside open (mid-handshake) fires close and doesn't hang", async () => {
using server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
tls,
socket: { data() {}, open() {}, close() {}, error() {} },
});

const events: string[] = [];
const { promise, resolve, reject } = Promise.withResolvers<void>();
await Bun.connect({
hostname: "127.0.0.1",
port: server.port,
tls: { rejectUnauthorized: false },
socket: {
open(s) {
events.push("open");
s.end();
},
// Presence of `handshake` is what makes `open` fire pre-handshake.
handshake(_s, ok, err) {
events.push(`handshake:${ok}:${err?.code ?? "-"}`);
},
close() {
events.push("close");
resolve();
},
data() {},
error(_s, e) {
reject(e);
},
connectError(_s, e) {
reject(e);
},
},
});
await promise;

// Closing mid-handshake surfaces ECONNRESET to `handshake` before `close`.
expect(events).toEqual(["open", "handshake:false:ECONNRESET", "close"]);
});
Loading