From 174085f6eed516f73b68e686b9d5cc50013defb9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:49:43 +0000 Subject: [PATCH] usockets(windows): keep a closed poll alive until the outer tick and libuv are done with it The libuv backend never counted tick_depth, so a nested tick (a handler that waits for a promise) freed the socket that the outer dispatch was still reading. The nested uv_run also ran the endgame of that socket's uv_poll_t under libuv's outer uv__fast_poll_process_poll_req frame, which then queued the endgame again, and close_cb_free_poll freed both blocks twice. us_loop_run and us_loop_pump now count tick_depth like the POSIX backend. While a poll_cb frame for a poll is on the stack, us_poll_stop only disarms the handle; the outermost frame issues the uv_close on its way back into libuv and then closes the socket, which the close paths hand over through us_internal_poll_close_fd (uv_close cancels the in-flight request with an ioctl on the socket, and a process with strict handle checks, such as an AppContainer, dies if the socket is already closed). us_poll_free and close_cb_free_poll record which of them ran first and the second one frees both blocks, which also covers a close callback that runs before us_poll_free, a us_poll_free without us_poll_stop, and us_poll_start_rc or us_poll_change on a handle libuv still references. --- packages/bun-usockets/src/context.c | 2 +- .../bun-usockets/src/eventing/epoll_kqueue.c | 4 + packages/bun-usockets/src/eventing/libuv.c | 186 ++++++++++++------ .../src/internal/eventing/libuv.h | 19 ++ packages/bun-usockets/src/internal/internal.h | 6 + .../bun-usockets/src/internal/loop_data.h | 8 +- packages/bun-usockets/src/socket.c | 4 +- packages/bun-usockets/src/udp.c | 2 +- test/js/bun/net/socket.test.ts | 93 +++++++++ test/js/bun/windows/appcontainer.test.ts | 36 ++++ 10 files changed, 295 insertions(+), 65 deletions(-) diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 0761aef4b317..344fa6da4216 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -482,7 +482,7 @@ void us_listen_socket_close(struct us_listen_socket_t *ls) { struct us_socket_group_t *group = ls->accept_group; struct us_loop_t *loop = s->group->loop; us_poll_stop((struct us_poll_t *) s, loop); - bsd_close_socket(us_poll_fd((struct us_poll_t *) s)); + us_internal_poll_close_fd((struct us_poll_t *) s); us_internal_listen_socket_ssl_free(ls); diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 068515494743..99a0a2319b12 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -763,6 +763,10 @@ void us_poll_stop(struct us_poll_t *p, struct us_loop_t *loop) { us_internal_loop_update_pending_ready_polls(loop, p, 0, old_events, new_events); } +void us_internal_poll_close_fd(struct us_poll_t *p) { + bsd_close_socket(us_poll_fd(p)); +} + size_t us_internal_accept_poll_event(struct us_poll_t *p) { #ifdef LIBUS_USE_EPOLL int fd = us_poll_fd(p); diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 3ea446c7bdc0..c71c2421c3e1 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -47,8 +47,11 @@ static struct us_socket_t *us_internal_poll_cb_adopted_socket(struct us_poll_t * return us_internal_socket_follow_adopted((struct us_socket_t *)wp); } -/* uv_poll_t->data always (except for most times after calling us_poll_stop) - * points to the us_poll_t */ +static void close_cb_free_poll(uv_handle_t *h); + +/* uv_poll_t->data always points to the us_poll_t (us_poll_resize moves it to + * the replacement block). libuv delivers no poll_cb once us_poll_stop has + * disarmed the handle, and nothing re-arms a stopped poll. */ static void poll_cb(uv_poll_t *p, int status, int events) { /* UV_DISCONNECT (Windows AFD): the peer closed its write side. A FIN * arriving after this side already half-closed and stopped reading never @@ -122,7 +125,28 @@ static void poll_cb(uv_poll_t *p, int status, int events) { if (!error && !eof && !(events & (UV_READABLE | UV_WRITABLE))) { return; } - us_internal_dispatch_ready_poll((struct us_poll_t *)p->data, error, eof, events); + struct us_poll_t *wp = (struct us_poll_t *)p->data; + wp->poll_cb_depth++; + us_internal_dispatch_ready_poll(wp, error, eof, events); + /* The dispatch may have relocated the poll (us_poll_resize); the counter + * was copied along, so finish on the block the handle points to now. */ + wp = (struct us_poll_t *)p->data; + /* uv_run is not reentrant, and a handler that waits for a promise runs it + * anyway. If such an inner run had closed this handle, it would also have + * run the handle's endgame while libuv's outer uv__fast_poll_process_poll_req + * frame (the caller of this function) was still using the handle; that + * frame then queues the endgame a second time. So us_poll_stop only disarms + * the handle while a poll_cb frame is on the stack, and the outermost frame + * closes it here, on its way back into libuv: a close from inside the + * callback is what libuv supports, and the endgame runs in the outer run. + * The socket itself is closed after the handle, as on the direct path in + * us_poll_stop (see close_fd). */ + if (--wp->poll_cb_depth == 0 && wp->stopped) { + uv_close((uv_handle_t *)p, close_cb_free_poll); + if (wp->close_fd) { + bsd_close_socket(wp->fd); + } + } } static void prepare_cb(uv_prepare_t *p) { @@ -139,13 +163,21 @@ static void check_cb(uv_check_t *p) { /* Not used for polls, since polls need two frees */ 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 */ +/* Polls have two blocks; whichever of us_poll_free and this callback runs + * second frees both (see us_poll_t). This one usually runs second: uv_close + * cancels the in-flight AFD request, us_poll_free runs from loop_post in the + * same iteration, and the cancellation is processed on a later one. It runs + * first for a socket closed during a nested tick (a handler that waits for a + * promise): the inner run completes the close, but loop_post leaves the closed + * list alone until the outermost tick (tick_depth), since the outer dispatch + * may still hold sockets on it. */ 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. */ - if (h->data) { - us_free(h->data); + struct us_poll_t *p = h->data; + if (p->released) { us_free(h); + us_free(p); + } else { + p->uv_closed = 1; } } @@ -168,39 +200,74 @@ void us_poll_init(struct us_poll_t *p, LIBUS_SOCKET_DESCRIPTOR fd, } void us_poll_free(struct us_poll_t *p, struct us_loop_t *loop) { - // poll was resized and dont own uv_poll_t anymore - if(!p->uv_p) { + uv_poll_t *h = p->uv_p; + /* us_poll_resize moved the handle to the replacement block, or + * us_poll_start_rc already freed it. */ + if (!h) { 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. - * 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. */ - if (uv_is_closing((uv_handle_t *)p->uv_p)) { - p->uv_p->data = p; - } else { - us_free(p->uv_p); + /* Never started (us_create_poll zeroes the handle): libuv has not seen it. + * Closed: libuv is done with it and close_cb_free_poll left it to us. */ + if (h->type != UV_POLL || p->uv_closed) { + us_free(h); us_free(p); + return; + } + /* Stopped, or still polling if the caller skipped us_poll_stop: an AFD + * request that completes into h may still be in flight, so h has to live + * until close_cb_free_poll, which now frees both blocks. */ + us_poll_stop(p, loop); + p->released = 1; +} + +/* One-way: on this backend a stopped poll is a closed (or closing) handle and + * cannot be started again. The blocks are freed later, see close_cb_free_poll. + * Callers close the socket afterwards through us_internal_poll_close_fd, which + * keeps it open for as long as the uv_close below is deferred. */ +void us_poll_stop(struct us_poll_t *p, struct us_loop_t *loop) { + uv_poll_t *h = p->uv_p; + if (!h || h->type != UV_POLL || p->stopped) return; + p->stopped = 1; + /* Disarm first: a completion this or an inner run has already dequeued must + * not reach poll_cb for a socket that is now on the closed list. */ + uv_poll_stop(h); + /* Inside this poll's own callback the outermost poll_cb frame closes the + * handle instead (see poll_cb). */ + if (p->poll_cb_depth == 0) { + uv_close((uv_handle_t *)h, close_cb_free_poll); } } +void us_internal_poll_close_fd(struct us_poll_t *p) { + /* The uv_close is still pending on the outermost poll_cb frame; it has to + * see the socket open (see close_fd in us_poll_t), so the frame closes both. */ + if (p->stopped && p->poll_cb_depth > 0) { + p->close_fd = 1; + return; + } + bsd_close_socket(p->fd); +} + int us_poll_start_rc(struct us_poll_t *p, struct us_loop_t *loop, int events) { - if(!p->uv_p) return 0; + uv_poll_t *h = p->uv_p; + if (!h) return 0; p->poll_type = us_internal_poll_type(p) | ((events & LIBUS_SOCKET_READABLE) ? POLL_TYPE_POLLING_IN : 0) | ((events & LIBUS_SOCKET_WRITABLE) ? POLL_TYPE_POLLING_OUT : 0); - /* uv_poll_init_socket (win/poll.c) can fail either before uv__handle_init - * (ioctlsocket FIONBIO) or after it (getsockopt SO_PROTOCOL_INFOW). The - * latter leaves the handle linked into loop->handle_queue with - * submitted_events_* still unset. Zero first so, on failure, ->type - * distinguishes the two states and the fields uv__poll_close reads are 0 - * rather than garbage. */ - memset(p->uv_p, 0, sizeof(uv_poll_t)); - p->uv_p->data = p; + if (h->type == UV_POLL) { + /* Already registered. Initializing it again would wipe the in-flight AFD + * requests and the loop's list links out from under libuv, and the next + * completion would land in a handle libuv no longer tracks. A stopped poll + * cannot be restarted; a live one only changes its mask. */ + if (p->stopped) { + errno = -UV_EBADF; + return UV_EBADF; + } + uv_poll_start(h, events | UV_DISCONNECT, poll_cb); + return 0; + } int rc; #if defined(LIBUS_SOCKET_FAULT_INJECTION) && LIBUS_SOCKET_FAULT_INJECTION @@ -210,20 +277,19 @@ int us_poll_start_rc(struct us_poll_t *p, struct us_loop_t *loop, int events) { rc = (int) injected; } else #endif - rc = uv_poll_init_socket(loop->uv_loop, p->uv_p, p->fd); + rc = uv_poll_init_socket(loop->uv_loop, h, p->fd); if (rc < 0) { int saved = LIBUS_ERR; - if (p->uv_p->type == UV_POLL) { - /* uv__handle_init ran: the handle is in loop->handle_queue. Close it - * through libuv so it is unlinked; the caller's us_poll_free sees - * uv_is_closing and hands ownership to close_cb_free_poll. */ - p->uv_p->data = 0; - uv_close((uv_handle_t *)p->uv_p, close_cb_free_poll); + /* uv_poll_init_socket (win/poll.c) fails either before uv__handle_init + * (ioctlsocket FIONBIO) or after it (getsockopt SO_PROTOCOL_INFOW). After + * it, the handle is in loop->handle_queue: close it through libuv so it is + * unlinked, and the caller's us_poll_free hands it to close_cb_free_poll. + * (uv__poll_close reads submitted_events_*, which init did not reach; they + * are 0 from us_create_poll.) Before it, the block is still only ours. */ + if (h->type == UV_POLL) { + us_poll_stop(p, loop); } else { - /* Never reached uv__handle_init: uv_p is still our raw block. Free it - * here and null the pointer so the caller's us_poll_free takes the - * !uv_p fast path (its uv_is_closing check would read garbage). */ - us_free(p->uv_p); + us_free(h); p->uv_p = NULL; } errno = saved ? saved : -rc; @@ -232,11 +298,11 @@ int us_poll_start_rc(struct us_poll_t *p, struct us_loop_t *loop, int events) { // This unref is okay in the context of Bun's event loop, because sockets have // a `Async.KeepAlive` associated with them, which is used instead of the // usockets internals. usockets doesnt have a notion of ref-counted handles. - uv_unref((uv_handle_t *)p->uv_p); + uv_unref((uv_handle_t *)h); /* Always ask for UV_DISCONNECT: a peer FIN must fire even when the poll is * writable-only at that moment (a half-closed connection whose reads are * paused is exactly the state that otherwise hangs; see poll_cb). */ - uv_poll_start(p->uv_p, events | UV_DISCONNECT, poll_cb); + uv_poll_start(h, events | UV_DISCONNECT, poll_cb); return 0; } @@ -245,7 +311,10 @@ void us_poll_start(struct us_poll_t *p, struct us_loop_t *loop, int events) { } int us_poll_change(struct us_poll_t *p, struct us_loop_t *loop, int events) { - if(!p->uv_p) return 0; + uv_poll_t *h = p->uv_p; + /* A stopped poll belongs to a socket on the closed list; re-arming it would + * deliver a poll_cb for that socket. */ + if (!h || p->stopped) return 0; if (us_poll_events(p) != events) { p->poll_type = us_internal_poll_type(p) | @@ -254,23 +323,11 @@ int us_poll_change(struct us_poll_t *p, struct us_loop_t *loop, int events) { /* The poll stays initialized across changes here (the dispatcher never * parks a libuv poll), so this cannot hit the registration failure the * epoll re-add can; uv_poll_start on a live poll only rejects bad args. */ - uv_poll_start(p->uv_p, events | UV_DISCONNECT, poll_cb); + uv_poll_start(h, events | UV_DISCONNECT, poll_cb); } return 0; } -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); -} - int us_poll_events(struct us_poll_t *p) { return ((p->poll_type & POLL_TYPE_POLLING_IN) ? LIBUS_SOCKET_READABLE : 0) | ((p->poll_type & POLL_TYPE_POLLING_OUT) ? LIBUS_SOCKET_WRITABLE : 0); @@ -294,9 +351,11 @@ void us_loop_pump(struct us_loop_t *loop) { * 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. */ + 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, @@ -374,15 +433,28 @@ void us_loop_run(struct us_loop_t *loop) { Bun__JSC_onBeforeWait(loop->data.jsc_vm, (uint64_t) uv_now(loop->uv_loop) * 1000000ULL); } + /* check_cb -> us_internal_loop_post frees the closed sockets only at depth + * 1: a poll callback that waits for a promise re-enters here, and the outer + * dispatch still holds the socket it is dispatching (same as the POSIX + * backend's us_loop_run_bun_tick). */ + loop->data.tick_depth++; 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, unsigned int ext_size) { struct us_poll_t *p = (struct us_poll_t *)us_malloc(sizeof(struct us_poll_t) + ext_size); - p->uv_p = us_malloc(sizeof(uv_poll_t)); + /* Zeroed so that ->type tells us_poll_free and us_poll_stop whether + * us_poll_start_rc ever registered the handle (uv__handle_init sets it). */ + p->uv_p = us_calloc(1, sizeof(uv_poll_t)); p->uv_p->data = p; + p->stopped = 0; + p->close_fd = 0; + p->uv_closed = 0; + p->released = 0; + p->poll_cb_depth = 0; return p; } diff --git a/packages/bun-usockets/src/internal/eventing/libuv.h b/packages/bun-usockets/src/internal/eventing/libuv.h index d9cf50cdd2ee..300124c49fa8 100644 --- a/packages/bun-usockets/src/internal/eventing/libuv.h +++ b/packages/bun-usockets/src/internal/eventing/libuv.h @@ -45,6 +45,25 @@ struct us_poll_t { uv_poll_t *uv_p; LIBUS_SOCKET_DESCRIPTOR fd; unsigned char poll_type; + /* us_poll_stop ran. uv_p is closing, or closes when poll_cb_depth drops to + * 0 (see poll_cb). Nothing re-arms the poll after this. */ + unsigned char stopped : 1; + /* us_internal_poll_close_fd ran while the close of uv_p was still deferred; + * fd is closed right after that close is issued. The order matters: uv_close + * cancels the in-flight request with an ioctl on the socket, and a process + * with strict handle checks (every AppContainer) dies on a closed handle. */ + unsigned char close_fd : 1; + /* Once us_poll_start_rc has registered uv_p, libuv keeps pointers into it + * (the in-flight AFD requests live inside the handle, and it sits in the + * loop's handle and endgame lists) until it runs the close callback. So the + * two blocks are freed by whichever of us_poll_free and close_cb_free_poll + * runs second; these record which one has already run. */ + unsigned char uv_closed : 1; + unsigned char released : 1; + /* Number of poll_cb frames for this poll on the stack. More than one means + * a handler re-entered the event loop (it waited for a promise) and the + * inner run dispatched this poll again. */ + unsigned int poll_cb_depth; }; #endif // LIBUV_H \ No newline at end of file diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index f8fd5d534f45..dfac6fb01727 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -208,6 +208,12 @@ void us_internal_async_wakeup(struct us_internal_async *a); size_t us_internal_accept_poll_event(struct us_poll_t *p); int us_internal_poll_type(struct us_poll_t *p); void us_internal_poll_set_type(struct us_poll_t *p, int poll_type); +/* Closes the descriptor of a poll that is being closed (after us_poll_stop, or + * the kqueue equivalent). On epoll/kqueue this is bsd_close_socket. On libuv + * the descriptor has to stay open until the handle's uv_close is issued, which + * a stop from inside the poll's own callback defers, so it is closed at that + * point instead (see us_poll_stop in eventing/libuv.c). */ +void us_internal_poll_close_fd(struct us_poll_t *p); /* SSL loop data */ void us_internal_init_loop_ssl_data(us_loop_r loop); diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 959a9110204c..16de6db502e9 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -88,10 +88,10 @@ 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 us_loop_run / us_loop_run_bun_tick (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/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index b84ec3a542ab..d6fbbd4f0a3f 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -199,7 +199,7 @@ void us_connecting_socket_close(struct us_connecting_socket_t *c) { us_internal_socket_group_unlink_socket(s->group, s); us_poll_stop((struct us_poll_t *) s, s->group->loop); - bsd_close_socket(us_poll_fd((struct us_poll_t *) s)); + us_internal_poll_close_fd((struct us_poll_t *) s); /* Link this socket to the close-list and let it be deleted after this iteration */ s->next = s->group->loop->data.closed_head; @@ -310,7 +310,7 @@ struct us_socket_t *us_internal_socket_close_raw(struct us_socket_t *s, int code setsockopt(us_poll_fd((struct us_poll_t *)s), SOL_SOCKET, SO_LINGER, (const char*)&l, sizeof(l)); } - bsd_close_socket(us_poll_fd((struct us_poll_t *) s)); + us_internal_poll_close_fd((struct us_poll_t *) s); /* Mark the socket as closed */ s->flags.is_closed = 1; diff --git a/packages/bun-usockets/src/udp.c b/packages/bun-usockets/src/udp.c index 77a6dcd7e294..b46f34593475 100644 --- a/packages/bun-usockets/src/udp.c +++ b/packages/bun-usockets/src/udp.c @@ -116,7 +116,7 @@ void us_udp_socket_close(struct us_udp_socket_t *s) { struct us_loop_t *loop = s->loop; struct us_poll_t *p = (struct us_poll_t *) s; us_poll_stop(p, loop); - bsd_close_socket(us_poll_fd(p)); + us_internal_poll_close_fd(p); s->closed = 1; s->next = loop->data.closed_udp_head; loop->data.closed_udp_head = s; diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 6619e16894c5..3f1724564579 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -12,6 +12,7 @@ import { isLinux, isWindows, libcPathForDlopen, + normalizeBunSnapshot, tempDir, tls, } from "harness"; @@ -4662,3 +4663,95 @@ describe.concurrent("a socket closed by data() while its peer's reset is being d expect(exitCode).toBe(0); }); }); + +// A handler can drive the event loop itself: expect(promise).resolves (like any other +// synchronous wait for a promise) ticks the loop until the promise settles, while the +// dispatch that called the handler is still on the stack. A socket closed in that +// handler goes on the loop's closed list, and the outer dispatch reads it again once +// the handler returns (loop.c, after data() and after open()). So the inner ticks must +// not free it: on POSIX they never did (tick_depth), on Windows the libuv backend freed +// it and, on top of that, ran the libuv close callback twice for it (libuv.c). +// +// The fixture runs under bun test in its own process, since the failure is a crash of +// the process: a segfault inside the dispatch on a debug build, heap corruption on a +// release build. Each round continues from a timer armed in the handler, so a marker +// proves that the dispatches around the handlers finished. +describe.concurrent("a handler that closes its socket and then waits for a promise", () => { + it("does not crash the dispatch it was called from", async () => { + const source = ` + import { expect, test } from "bun:test"; + + // The close of the socket is completed by the loop (on Windows: the cancelled + // poll request completes, then libuv runs the close callback). Nothing in JS + // observes that, so the handler holds the loop for a short time instead of + // waiting for an event; the inner ticks pick the completion up right away. + function waitInsideHandler() { + expect(Bun.sleep(20)).resolves.toBeUndefined(); + } + + test("data() closes the accepted socket", async () => { + for (let i = 0; i < 3; i++) { + const dispatched = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket) { + socket.terminate(); + waitInsideHandler(); + setTimeout(dispatched.resolve, 0); + }, + }, + }); + const peer = await Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {} } }); + peer.write("x"); + await dispatched.promise; + peer.terminate(); + } + console.log("data() done"); + }); + + test("open() closes the accepted socket and the listener", async () => { + for (let i = 0; i < 3; i++) { + const dispatched = Promise.withResolvers(); + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + socket.terminate(); + server.stop(true); + waitInsideHandler(); + setTimeout(dispatched.resolve, 0); + }, + data() {}, + }, + }); + // The accepted socket is reset before this side's open() runs, so the connect + // can be reported as failed. Either way the server side has been dispatched. + const peer = Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {} } }).then( + socket => socket, + () => null, + ); + await dispatched.promise; + (await peer)?.terminate(); + } + console.log("open() done"); + }); + `; + using dir = tempDir("socket-close-then-wait-in-handler", { "fixture.test.ts": source }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "fixture.test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: normalizeBunSnapshot(stdout), exitCode, stderr: exitCode === 0 ? "" : stderr }).toEqual({ + stdout: "bun test ()\ndata() done\nopen() done", + exitCode: 0, + stderr: "", + }); + }); +}); diff --git a/test/js/bun/windows/appcontainer.test.ts b/test/js/bun/windows/appcontainer.test.ts index c98daaf770cc..b0d0bf11b39d 100644 --- a/test/js/bun/windows/appcontainer.test.ts +++ b/test/js/bun/windows/appcontainer.test.ts @@ -317,6 +317,39 @@ async function main() { } })(); + // AppContainers run with strict handle checks: any call on a closed handle + // ends the process (exit 0xC0000008) instead of failing. A socket closed from + // inside its own data() handler is the case where usockets closes the libuv + // poll and the socket on the way out of the handler (eventing/libuv.c); the + // poll has to be closed while the socket is still open, or libuv's cancel of + // the in-flight poll request hits a closed handle. The client is closed the + // same way, by the dispatch of the reset it receives. + r.closeInHandler = await (async () => { + let server; + try { + server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data(socket) { socket.terminate(); } } }); + } catch (e) { + return "listen:" + (e.code || e); + } + try { + const closed = new Promise(resolve => { + const timer = setTimeout(() => resolve("timeout waiting for the reset"), 15000); + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(socket) { socket.write("x"); }, + data() {}, + close() { clearTimeout(timer); resolve("OK"); }, + }, + }).catch(e => { clearTimeout(timer); resolve("connect:" + (e.code || e)); }); + }); + return await closed; + } finally { + server.stop(true); + } + })(); + fs.writeFileSync("results.json", JSON.stringify(r)); } main().then( @@ -350,6 +383,9 @@ main().then( // classified outcome; this guards only an unset key, an unclassified // crash, or a served wrong body. Pin the value once container CI reports it. expect(String(r.serveFetch)).toMatch(/^(OK$|listen:|fetch:)/); + // Same loopback caveat as serveFetch. The exit code above is the real + // check: a closed handle touched on the way out of data() ends the child. + expect(String(r.closeInHandler)).toMatch(/^(OK$|listen:|connect:)/); }, 90_000, );