diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index c984d118915f..2ab71a78138e 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -181,11 +181,14 @@ static void close_cb_free(uv_handle_t *h) { us_free(h->data); } /* 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. */ + /* us_poll_free normally re-pointed data at the us_poll_t before this runs. + * If a nested tick's loop_post deferred the closed-socket sweep (see + * tick_depth in us_loop_run), we run first: mark the handle for us_poll_free. */ if (h->data) { us_free(h->data); us_free(h); + } else { + h->data = h; } } @@ -213,6 +216,14 @@ void us_poll_free(struct us_poll_t *p, struct us_loop_t *loop) { us_free(p); return; } + /* close_cb_free_poll already ran: a nested tick deferred this sweep to the + * outermost loop_post (tick_depth), so libuv finished closing the handle + * before we got here. It is done with uv_p; we free both. */ + if (p->uv_p->data == (void *)p->uv_p) { + us_free(p->uv_p); + us_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. @@ -333,10 +344,19 @@ void us_loop_pump(struct us_loop_t *loop) { * for unref'd handles (subprocess exit packets, socket events) and due * timers are never processed. Bun's outer drive loops (wait_for_promise, * bun:test) supply their own keep-going predicate, so force exactly one - * non-blocking iteration; UV_RUN_NOWAIT keeps the poll timeout at 0. */ + * non-blocking iteration; UV_RUN_NOWAIT keeps the poll timeout at 0. + * + * Bun enters here instead of us_loop_run whenever nothing ref's the loop, so + * this can be the outermost tick of a close-then-re-enter sequence: a poll + * callback dispatched by this iteration closes its socket (uv_close makes + * the loop active again) and re-enters through us_loop_run. That nested + * tick's loop_post must see depth 2, or it sweeps the socket this + * iteration's dispatch still holds. See us_internal_loop_post. */ + loop->data.tick_depth++; loop->uv_loop->active_handles++; uv_run(loop->uv_loop, UV_RUN_NOWAIT); loop->uv_loop->active_handles--; + loop->data.tick_depth--; } struct us_loop_t *us_create_loop(void *hint, @@ -405,6 +425,11 @@ void us_loop_run(struct us_loop_t *loop) { us_loop_integrate(loop); uv_update_time(loop->uv_loop); + /* us_internal_loop_post runs from the uv_check_t registered above, so it + * fires inside uv_run; the tick_depth bracket mirrors us_loop_run and + * us_loop_run_bun_tick in epoll_kqueue.c. See us_internal_loop_post. */ + loop->data.tick_depth++; + /* UV_RUN_ONCE may block in the poll phase (pending callbacks dispatch * first), making this the JS thread's park hook, the counterpart of * us_loop_run_bun_tick's. jsc_vm is only set on the JS thread's loop. */ @@ -415,6 +440,7 @@ void us_loop_run(struct us_loop_t *loop) { } uv_run(loop->uv_loop, UV_RUN_ONCE); + loop->data.tick_depth--; } struct us_poll_t *us_create_poll(struct us_loop_t *loop, int fallthrough, diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 3937ea5d70d5..b4fad5c3feb5 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -95,10 +95,11 @@ struct us_internal_loop_data_t { /* We do not care if this flips or not, it doesn't matter */ size_t iteration_nr; void* jsc_vm; - /* Reentrancy depth of us_loop_run_bun_tick. When >1, we are inside a - * nested tick (e.g. waitForPromise from a poll callback). Freeing closed - * sockets must be deferred to the outermost tick so the outer dispatch - * doesn't read a freed poll. */ + /* Reentrancy depth of the loop-run entry points (us_loop_run_bun_tick and + * us_loop_run on epoll/kqueue; us_loop_run and us_loop_pump on libuv). + * When >1 we are inside a nested tick (e.g. waitForPromise from a poll + * callback). Freeing closed sockets must be deferred to the outermost + * tick so the outer dispatch doesn't read a freed poll. */ int tick_depth; }; diff --git a/patches/libuv/win-poll-no-reendgame-after-close.patch b/patches/libuv/win-poll-no-reendgame-after-close.patch new file mode 100644 index 000000000000..6f6e536bd262 --- /dev/null +++ b/patches/libuv/win-poll-no-reendgame-after-close.patch @@ -0,0 +1,29 @@ +--- a/src/win/poll.c ++++ b/src/win/poll.c +@@ -217,8 +217,14 @@ static void uv__fast_poll_process_poll_req(uv_loop_t* loop, uv_poll_t* handle, + handle->submitted_events_2)) != 0) { + uv__fast_poll_submit_poll_req(loop, handle); + } else if ((handle->flags & UV_HANDLE_CLOSING) && ++ !(handle->flags & UV_HANDLE_CLOSED) && + handle->submitted_events_1 == 0 && + handle->submitted_events_2 == 0) { ++ /* A nested uv_run entered from inside poll_cb may have already run this ++ * handle's endgame: uv__process_endgames cleared ENDGAME_QUEUED and ++ * uv__handle_close set CLOSED. Re-queuing it here would run the endgame ++ * again, which uv__poll_endgame asserts against and which invokes ++ * close_cb a second time. */ + uv__want_endgame(loop, (uv_handle_t*) handle); + } + } +@@ -418,8 +424,11 @@ static void uv__slow_poll_process_poll_req(uv_loop_t* loop, uv_poll_t* handle, + handle->submitted_events_2)) != 0) { + uv__slow_poll_submit_poll_req(loop, handle); + } else if ((handle->flags & UV_HANDLE_CLOSING) && ++ !(handle->flags & UV_HANDLE_CLOSED) && + handle->submitted_events_1 == 0 && + handle->submitted_events_2 == 0) { ++ /* Same as the fast path: never re-queue an endgame that a nested uv_run ++ * already ran for this handle. */ + uv__want_endgame(loop, (uv_handle_t*) handle); + } + } diff --git a/scripts/build/deps/libuv.ts b/scripts/build/deps/libuv.ts index 067ded49ed83..150ec527264e 100644 --- a/scripts/build/deps/libuv.ts +++ b/scripts/build/deps/libuv.ts @@ -56,7 +56,18 @@ export const libuv: Dependency = { // an in-process loopback fetch().abort() can fall into. To upstream: // send to libuv/libuv with the wepoll/ReactOS references in the patch // comment as the rationale. - patches: ["patches/libuv/win-poll-rearm-before-callback.patch", "patches/libuv/win-poll-abort-with-disconnect.patch"], + // + // win-poll-no-reendgame-after-close: the post-poll_cb endgame check in + // uv__fast_poll_process_poll_req (and its slow-poll sibling) re-queues a + // handle whose endgame a nested uv_run, entered from inside poll_cb, + // already ran, which double-invokes close_cb. Guard the check on + // !(flags & UV_HANDLE_CLOSED), which uv__poll_endgame already asserts. + // Nested uv_run is outside libuv's contract, so this is not upstreamable. + patches: [ + "patches/libuv/win-poll-rearm-before-callback.patch", + "patches/libuv/win-poll-abort-with-disconnect.patch", + "patches/libuv/win-poll-no-reendgame-after-close.patch", + ], build: () => ({ kind: "direct", diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 94b15d060b82..228148acb7ee 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -4157,3 +4157,107 @@ describe.concurrent.each(["tcp", "tls"] as const)("%s socket paused when its pee }); }); }); + +// On the libuv (Windows) event loop backend, a synchronous re-entrant loop +// tick from inside a socket's data callback ran the closed-socket sweep and +// freed the us_socket_t the suspended outer dispatch frame still held. +// +// Bun picks the uws entry point at the top of each tick from libuv's +// active_handles count: non-zero => us_loop_run, zero => us_loop_pump. The +// two cases drive the same close-then-re-enter sequence through each one. +// On Windows the subprocess also reports active_handles as seen from inside +// data(): 3 in the ref'd case (listener, accepted socket, client), and 1 in +// the unref'd case, which is us_loop_pump's own increment, i.e. proof that +// the dispatch really came through the pump. +describe.concurrent.each([ + { name: "ref'd (us_loop_run)", unref: false, activeHandlesInData: 3 }, + { name: "unref'd (us_loop_pump)", unref: true, activeHandlesInData: 1 }, +])( + "closing a socket and re-entering the event loop from its own data callback, $name", + ({ unref, activeHandlesInData }) => { + it("survives", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const UNREF = ${unref}; + const { getEventLoopStats } = require("bun:internal-for-testing"); + // The setImmediate callback only runs inside the nested tick that + // transform() spins while it waits for the handler's promise, so + // reentries === 20 proves every round really re-entered the loop. + let reentries = 0; + const activeHandles = new Set(); + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(sock) { + if (UNREF) sock.unref(); + }, + data(sock) { + activeHandles.add(getEventLoopStats().numPolls); + // Synchronously close the socket, then synchronously re-enter + // the event loop. transform() ticks the loop until the handler's + // promise settles and then throws because it cannot return the + // result synchronously; the nested tick is the part that matters. + sock.terminate(); + try { + new HTMLRewriter() + .on("p", { element: () => new Promise(r => setImmediate(() => { reentries++; r(); })) }) + .transform("

"); + } catch {} + }, + close() {}, + error() {}, + }, + }); + if (UNREF) server.unref(); + for (let i = 0; i < 20; i++) { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(s) { + if (UNREF) s.unref(); + // Write from the top of the next tick rather than from inside + // open(): the immediate releases its own keep-alive before the + // tick chooses its entry point, so in the unref'd case the tick + // that dispatches data() sees zero active handles. + setImmediate(() => s.write("x")); + }, + data() {}, + close: resolve, + end: resolve, + error: resolve, + connectError: resolve, + }, + }).catch(resolve); + await promise; + } + server.stop(true); + console.log(JSON.stringify({ reentries, activeHandles: [...activeHandles] })); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A crashed child prints nothing; keep the parse from throwing so the + // failure diff still shows its stderr and exit code. + expect({ result: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ + result: { + reentries: 20, + // numPolls is libuv's active_handles on Windows; on the other + // backends it counts polls and the run/pump distinction does not exist. + activeHandles: isWindows ? [activeHandlesInData] : expect.any(Array), + }, + stderr: expect.any(String), + exitCode: 0, + }); + }); + }, +);