From 775d52bcb9fbb866c65ad142aed9022f14c8ee7a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:39:47 +0000 Subject: [PATCH 1/9] usockets: stop pause() from arming writable interest it never had us_socket_pause forced the poll to WRITABLE. Two consequences: - The always-writable socket immediately dispatched a writable event, so every pause() with nothing buffered fired a bogus JS drain. - On a socket whose write side we already shut down, the fresh kqueue EVFILT_WRITE one-shot reported our own SS_CANTSENDMORE as EV_EOF instantly, and the eof dispatch closed the half-closed socket within milliseconds even though the peer was alive and silent (verified on macOS; Linux kept it open, so the platforms diverged). libuv's uv_read_stop only ever removes read interest. pause() now only keeps pre-existing writable interest (a backpressured write stays armed), and kqueue_change's 0-event fallback (the one-shot write filter armed to catch peer teardown) skips sockets we shut down ourselves: for them any write filter completes instantly with our own EV_EOF and can never distinguish anything. --- .../bun-usockets/src/eventing/epoll_kqueue.c | 11 ++- packages/bun-usockets/src/socket.c | 10 ++- test/js/bun/net/socket.test.ts | 70 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 4dce3538d154..1d5704b19d45 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -543,8 +543,15 @@ int kqueue_change(int kqfd, int fd, int old_events, int new_events, void *user_d } if(!is_readable && !is_writable) { - if(!(old_events & LIBUS_SOCKET_WRITABLE)) { - // if we are not reading or writing, we need to add writable to receive FIN + /* 0-event poll: arm a one-shot write filter so a peer teardown still + * has an event to ride (EV_EOF on EVFILT_WRITE; epoll gets this for + * free via the implicit EPOLLHUP|EPOLLERR). Never for a socket whose + * write side WE shut down: our own SS_CANTSENDMORE makes any write + * filter report EV_EOF instantly, which read as the connection being + * over and closed a paused half-closed socket whose peer was alive. */ + int own_shutdown = user_data && + us_internal_poll_type((struct us_poll_t *) user_data) == POLL_TYPE_SOCKET_SHUT_DOWN; + if(!(old_events & LIBUS_SOCKET_WRITABLE) && !own_shutdown) { EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, EV_ADD | EV_ONESHOT, 0, 0, (uint64_t)(void*)user_data, 0, 0); } } else if ((new_events & LIBUS_SOCKET_WRITABLE) != (old_events & LIBUS_SOCKET_WRITABLE)) { diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 25f798b1a017..3b2312d2ff64 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -840,8 +840,14 @@ void us_socket_pause(struct us_socket_t *s) { if (s->flags.is_paused) return; // closed cannot be paused because it is already closed if (us_socket_is_closed(s)) return; - // we are readable and writable so we can just pause readable side - us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_WRITABLE); + /* Drop readable interest but only KEEP writable interest, never add it: + * forcing WRITABLE here dispatched a bogus writable (a JS drain event + * with nothing buffered) on every pause, and on a shut-down socket the + * fresh kqueue EVFILT_WRITE one-shot reported our own SS_CANTSENDMORE + * as EV_EOF immediately, closing a half-closed socket whose peer was + * still alive (libuv's uv_read_stop only removes read interest). A + * backpressured write keeps its interest; none means nothing to drain. */ + us_poll_change(&s->p, s->group->loop, us_poll_events(&s->p) & LIBUS_SOCKET_WRITABLE); s->flags.is_paused = 1; } diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index f9e7ae1f55c1..a87afe46dddf 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -362,6 +362,76 @@ describe.concurrent("socket", () => { expect(await bunRun(fileURLToPath(new URL("./kqueue-filter-coalesce-fixture.ts", import.meta.url)))).toSpawn(); }); + // us_socket_pause armed WRITABLE unconditionally; the always-writable socket + // then dispatched a bogus drain with nothing buffered. + it("pause() with nothing buffered must not fire a drain event", async () => { + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, + }); + let drains = 0; + const opened = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open: s => opened.resolve(s), + drain() { + drains++; + }, + data() {}, + end() {}, + error() {}, + close() {}, + }, + }); + const s = await opened.promise; + await Bun.sleep(50); // let any connect-time writable settle + const before = drains; + s.pause(); + await Bun.sleep(100); // window in which the buggy pause-armed writable fired + expect(drains - before).toBe(0); + s.terminate(); + }); + + // On kqueue, pause() after shutdown() armed an EVFILT_WRITE one-shot that + // reported our own SS_CANTSENDMORE as EV_EOF immediately: the half-closed + // socket closed within milliseconds even though the peer was alive and + // silent. Linux always kept it open; this pins the behavior on both. + it("shutdown() then pause() keeps a half-closed socket open while the peer is silent", async () => { + let closedEarly = false; + const opened = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(s) { + s.shutdown(); + s.pause(); + opened.resolve(); + }, + data() {}, + end() {}, + error() {}, + close() { + closedEarly = true; + }, + }, + }); + // allowHalfOpen peer ignores our FIN and stays silently connected. + const peer = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + allowHalfOpen: true, + socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, + }); + await opened.promise; + await Bun.sleep(1000); + expect(closedEarly).toBe(false); + peer.terminate(); + }); + it("reload() should preserve active_connections (no UAF / counter underflow)", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), fileURLToPath(new URL("./socket-reload-fixture.ts", import.meta.url))], From 199dbf3b7be786bf3a12f09f7431355e9d5dfdcd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:23:39 +0000 Subject: [PATCH 2/9] usockets: explicit writable kicks replace the pause-armed one; kqueue teardown watch for shut-down sockets CI caught node:http pipelined flood prevention deadlocking: the park/ replay machinery (HTTP_NODE_READS_PAUSED -> onWritable tail -> Bun__NodeHTTP__onReadsResumable) relied on the writable event pause() used to force-arm. The queued pipelined responses live in the JS pipeline queue (or the AsyncSocket buffer) without any kernel send having been attempted, so no write failure ever arms the poll and the replay never ran: requests parked forever. New us_socket_mark_writable_pending() arms writable interest for bytes held outside the socket's own write path; the three sites that park requests behind queued responses (onReadsPaused and both HttpContext backpressure branches) now ask for their flush/replay wakeup explicitly instead of riding a side effect of pause(). Review fix: the pause change left a paused shut-down kqueue socket with zero filters, so the peer's FIN/RST was never delivered (epoll keeps the implicit EPOLLHUP|EPOLLERR). kqueue_change now arms a read-side teardown watch (EV_ADD|EV_CLEAR: the read filter's EV_EOF is the peer's FIN/RST, never our own SS_CANTSENDMORE echo) for 0-event polls on shut-down sockets, deleting any leftover write one-shot, and us_internal_socket_raw_shutdown arms it directly when the poll was already at 0 events (the diff would no-op). New test pins the flip side: shutdown+pause still closes when the peer terminates. Also from review: the silent-peer window is 250ms with rationale, and both tests release their sockets before asserting. --- .../bun-usockets/src/eventing/epoll_kqueue.c | 31 +++++++---- packages/bun-usockets/src/internal/internal.h | 7 +++ packages/bun-usockets/src/libusockets.h | 6 +++ packages/bun-usockets/src/socket.c | 20 ++++++++ packages/bun-uws/src/HttpContext.h | 9 ++++ .../bindings/node/JSNodeHTTPServerSocket.cpp | 5 ++ test/js/bun/net/socket.test.ts | 51 +++++++++++++++++-- 7 files changed, 115 insertions(+), 14 deletions(-) diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 1d5704b19d45..67e0af8d1b9e 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -538,19 +538,32 @@ int kqueue_change(int kqfd, int fd, int old_events, int new_events, void *user_d /* Do they differ in readable? */ int is_readable = (new_events & LIBUS_SOCKET_READABLE); int is_writable = (new_events & LIBUS_SOCKET_WRITABLE); - if ((new_events & LIBUS_SOCKET_READABLE) != (old_events & LIBUS_SOCKET_READABLE)) { + /* 0-event polls need a filter for peer teardown to ride (epoll gets this + * for free via the implicit EPOLLHUP|EPOLLERR). For a socket whose write + * side WE shut down, a write filter is useless: our own SS_CANTSENDMORE + * makes it report EV_EOF instantly, which read as the connection being + * over and closed a paused half-closed socket whose peer was alive. The + * read filter has no such echo - its EV_EOF is the PEER's FIN/RST - so + * keep one armed (EV_CLEAR: buffered data fires once and is masked by the + * dispatcher, instead of level-triggering every tick). */ + int own_shutdown = user_data && + us_internal_poll_type((struct us_poll_t *) user_data) == POLL_TYPE_SOCKET_SHUT_DOWN; + int teardown_watch = !is_readable && !is_writable && own_shutdown; + if (teardown_watch) { + EV_SET64(&change_list[change_length++], fd, EVFILT_READ, EV_ADD | EV_CLEAR, 0, 0, (uint64_t)(void*)user_data, 0, 0); + if (old_events & LIBUS_SOCKET_WRITABLE) { + /* A still-armed write one-shot would report our own + * SS_CANTSENDMORE; nothing can ever be sent again anyway. */ + EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, EV_DELETE, 0, 0, (uint64_t)(void*)user_data, 0, 0); + } + } else if ((new_events & LIBUS_SOCKET_READABLE) != (old_events & LIBUS_SOCKET_READABLE)) { EV_SET64(&change_list[change_length++], fd, EVFILT_READ, is_readable ? EV_ADD : EV_DELETE, 0, 0, (uint64_t)(void*)user_data, 0, 0); } if(!is_readable && !is_writable) { - /* 0-event poll: arm a one-shot write filter so a peer teardown still - * has an event to ride (EV_EOF on EVFILT_WRITE; epoll gets this for - * free via the implicit EPOLLHUP|EPOLLERR). Never for a socket whose - * write side WE shut down: our own SS_CANTSENDMORE makes any write - * filter report EV_EOF instantly, which read as the connection being - * over and closed a paused half-closed socket whose peer was alive. */ - int own_shutdown = user_data && - us_internal_poll_type((struct us_poll_t *) user_data) == POLL_TYPE_SOCKET_SHUT_DOWN; + /* One-shot write filter as the teardown ride for 0-event polls that + * did not shut down themselves (see above; their read side may be + * gone entirely, e.g. half-open after on_end). */ if(!(old_events & LIBUS_SOCKET_WRITABLE) && !own_shutdown) { EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, EV_ADD | EV_ONESHOT, 0, 0, (uint64_t)(void*)user_data, 0, 0); } diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 38106f69c95a..ddaa9e0699e7 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -51,6 +51,13 @@ void us_internal_loop_update_pending_ready_polls(struct us_loop_t *loop, int new_events); #endif +#ifdef LIBUS_USE_KQUEUE +/* Defined in eventing/epoll_kqueue.c. Applies an interest-set transition as + * kevent changes; a 0->0 transition on a shut-down socket's poll arms the + * read-side teardown watch (see us_internal_socket_raw_shutdown). */ +int kqueue_change(int kqfd, int fd, int old_events, int new_events, void *user_data); +#endif + /* We only have one networking implementation so far */ #include "internal/networking/bsd.h" diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 1040e1377585..158ef203b84a 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -725,6 +725,12 @@ int us_socket_set_tos(us_socket_r s, int tos); int us_socket_get_tos(us_socket_r s); void us_socket_resume(us_socket_r s); void us_socket_pause(us_socket_r s); +/* Arm writable interest for bytes the caller holds OUTSIDE the socket's own + * write path (uws's queued pipelined responses sit in the AsyncSocket buffer + * without any send having been attempted, so no write failure ever armed the + * poll). The next writable event flushes them. Respects pause: readable + * interest is not re-added. */ +void us_socket_mark_writable_pending(us_socket_r s); #ifdef __cplusplus } diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 3b2312d2ff64..c01dc8c734a0 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -418,6 +418,17 @@ static void us_internal_rearm_writable(struct us_socket_t *s) { LIBUS_SOCKET_WRITABLE | (s->flags.is_paused ? 0 : LIBUS_SOCKET_READABLE)); } +/* See libusockets.h. last_write_failed makes the writable dispatch keep the + * interest armed until a flush succeeds (it is cleared at writable-event + * entry and re-set by any failing write). */ +void us_socket_mark_writable_pending(struct us_socket_t *s) { + if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { + return; + } + s->flags.last_write_failed = 1; + us_internal_rearm_writable(s); +} + int us_socket_write2(struct us_socket_t *s, const char *header, int header_length, const char *payload, int payload_length) { if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { return 0; @@ -709,6 +720,15 @@ void us_internal_socket_raw_shutdown(struct us_socket_t *s) { if (!us_socket_is_closed(s) && us_internal_poll_type(&s->p) != POLL_TYPE_SOCKET_SHUT_DOWN) { us_internal_poll_set_type(&s->p, POLL_TYPE_SOCKET_SHUT_DOWN); us_poll_change(&s->p, s->group->loop, us_poll_events(&s->p) & LIBUS_SOCKET_READABLE); +#ifdef LIBUS_USE_KQUEUE + /* A socket already at 0 events (paused with nothing buffered) diffs + * to a no-op above, leaving no kqueue filter at all; arm the + * read-side teardown watch directly so the peer's FIN/RST still + * closes us (epoll's implicit EPOLLHUP|EPOLLERR needs no filter). */ + if (us_poll_events(&s->p) == 0) { + kqueue_change(s->group->loop->fd, us_poll_fd(&s->p), 0, 0, &s->p); + } +#endif bsd_shutdown_socket(us_poll_fd((struct us_poll_t *) s)); } } diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 2a88ad687ad8..c3eb2d432d5f 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -421,6 +421,11 @@ struct HttpContext { * right now — pausing the socket alone cannot bound it. */ httpResponseData->nodeHttpParkAtNextBoundary = true; ((HttpResponse *) s)->pause(); + /* The buffered bytes were queued without a kernel write + * (response ordering), so no write failure armed the poll; + * the onWritable that flushes them and replays the parked + * requests needs explicit writable interest. */ + us_socket_mark_writable_pending((us_socket_t *) s); } } } else { @@ -454,6 +459,10 @@ struct HttpContext { httpResponseData->state |= HttpResponseData::HTTP_NODE_READS_PAUSED; httpResponseData->nodeHttpParkAtNextBoundary = true; ((HttpResponse *) s)->pause(); + /* Same as the pipelined branch above: the queued bytes + * never hit the kernel, so arm the writable that will + * flush them and replay the parked requests. */ + us_socket_mark_writable_pending((us_socket_t *) s); } } } diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index e7d895f5e3b8..4f60efc5e64f 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -500,6 +500,11 @@ static void onNodeHttpReadsPaused(us_socket_t* socket) auto* d = reinterpret_cast*>(us_socket_ext(socket)); d->nodeHttpParkAtNextBoundary = true; d->state |= uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; + // The replay of parked requests (and the eventual read resume) runs from + // onWritable, but the queued pipelined responses live in the JS pipeline + // queue: no socket write happens here to arm the poll, so ask for the + // writable event explicitly. + us_socket_mark_writable_pending(socket); } extern "C" void Bun__NodeHTTP__onReadsPaused(int ssl, us_socket_t* socket) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index a87afe46dddf..8c10662dc531 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -391,8 +391,9 @@ describe.concurrent("socket", () => { const before = drains; s.pause(); await Bun.sleep(100); // window in which the buggy pause-armed writable fired - expect(drains - before).toBe(0); - s.terminate(); + const delta = drains - before; + s.terminate(); // release before asserting so a failure does not leak the socket + expect(delta).toBe(0); }); // On kqueue, pause() after shutdown() armed an EVFILT_WRITE one-shot that @@ -427,9 +428,49 @@ describe.concurrent("socket", () => { socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, }); await opened.promise; - await Bun.sleep(1000); - expect(closedEarly).toBe(false); - peer.terminate(); + // Negative-assertion window: the buggy kqueue EV_EOF closed within ~2ms, + // so 250ms is >100x margin without spending the whole per-test budget. + await Bun.sleep(250); + const closed = closedEarly; + peer.terminate(); // release before asserting so a failure does not leak the socket + expect(closed).toBe(false); + }); + + // The flip side: with the write filter unusable after our own shutdown() + // (its EV_EOF echoes SS_CANTSENDMORE), the read-side teardown watch must + // still deliver the peer's actual termination to a paused half-closed + // socket (epoll gets this via the implicit EPOLLHUP|EPOLLERR). + it("shutdown() then pause() still closes when the peer terminates", async () => { + const closed = Promise.withResolvers(); + const opened = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(s) { + s.shutdown(); + s.pause(); + opened.resolve(); + }, + data() {}, + end() {}, + error() {}, + close() { + closed.resolve(); + }, + }, + }); + const peerOpened = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + allowHalfOpen: true, + socket: { open: s => peerOpened.resolve(s), data() {}, end() {}, error() {}, close() {} }, + }); + const peer = await peerOpened.promise; + await opened.promise; + peer.terminate(); // RST; the victim's close must still fire while paused + await closed.promise; }); it("reload() should preserve active_connections (no UAF / counter underflow)", async () => { From 3165060870051db53416218b7d8977b98a520b94 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:09:42 +0000 Subject: [PATCH 3/9] usockets: scrub the write filter unconditionally in the teardown watch; resume() keeps rather than manufactures writable Review catches, both the same class as the PR: - pause() before shutdown() armed the 0-event fallback write one-shot while own_shutdown was still false, and the teardown transition's conditional delete read the caller's old_events (a literal 0 from the raw_shutdown direct call), so the phantom one-shot survived and echoed our own SS_CANTSENDMORE as EV_EOF: the sibling ordering still closed a paused half-closed socket prematurely on kqueue. The teardown watch now deletes EVFILT_WRITE unconditionally (ENOENT receipt is harmless when none exists). New test pins the pause-then-shutdown ordering. - us_socket_resume manufactured WRITABLE the same way pause() used to, firing one bogus drain per pause/resume round trip; it now re-adds readable and only keeps pre-existing writable interest (backpressure during the pause already re-armed it). The drain test now covers the full round trip. --- .../bun-usockets/src/eventing/epoll_kqueue.c | 11 ++--- packages/bun-usockets/src/socket.c | 9 ++++- test/js/bun/net/socket.test.ts | 40 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 67e0af8d1b9e..30231d4dbf1a 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -551,11 +551,12 @@ int kqueue_change(int kqfd, int fd, int old_events, int new_events, void *user_d int teardown_watch = !is_readable && !is_writable && own_shutdown; if (teardown_watch) { EV_SET64(&change_list[change_length++], fd, EVFILT_READ, EV_ADD | EV_CLEAR, 0, 0, (uint64_t)(void*)user_data, 0, 0); - if (old_events & LIBUS_SOCKET_WRITABLE) { - /* A still-armed write one-shot would report our own - * SS_CANTSENDMORE; nothing can ever be sent again anyway. */ - EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, EV_DELETE, 0, 0, (uint64_t)(void*)user_data, 0, 0); - } + /* Unconditionally drop any write filter: a still-armed one-shot would + * report our own SS_CANTSENDMORE, and old_events cannot be trusted to + * know about it - pause() before shutdown() arms the 0-event fallback + * one-shot without recording it anywhere (ENOENT from the receipt is + * harmless when none exists). Nothing can ever be sent again anyway. */ + EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, EV_DELETE, 0, 0, (uint64_t)(void*)user_data, 0, 0); } else if ((new_events & LIBUS_SOCKET_READABLE) != (old_events & LIBUS_SOCKET_READABLE)) { EV_SET64(&change_list[change_length++], fd, EVFILT_READ, is_readable ? EV_ADD : EV_DELETE, 0, 0, (uint64_t)(void*)user_data, 0, 0); } diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index c01dc8c734a0..9eccfce28c89 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -890,6 +890,11 @@ void us_socket_resume(struct us_socket_t *s) { us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_READABLE); return; } - // we are readable and writable so we resume everything - us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); + /* Re-add readable, but like pause() only KEEP writable interest: any + * backpressure during the pause already armed it via + * us_internal_rearm_writable, and manufacturing it here fired a bogus + * drain on every pause/resume round trip (libuv's uv_read_start only + * adds POLLIN). */ + us_poll_change(&s->p, s->group->loop, + LIBUS_SOCKET_READABLE | (us_poll_events(&s->p) & LIBUS_SOCKET_WRITABLE)); } diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 8c10662dc531..21e7577fc2b8 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -391,6 +391,8 @@ describe.concurrent("socket", () => { const before = drains; s.pause(); await Bun.sleep(100); // window in which the buggy pause-armed writable fired + s.resume(); + await Bun.sleep(100); // resume() manufacturing WRITABLE fired one too const delta = drains - before; s.terminate(); // release before asserting so a failure does not leak the socket expect(delta).toBe(0); @@ -436,6 +438,44 @@ describe.concurrent("socket", () => { expect(closed).toBe(false); }); + // The sibling ordering: pause() first arms the 0-event fallback write + // one-shot before the socket is shut down, and the teardown transition must + // still scrub it or it echoes our own SS_CANTSENDMORE as EV_EOF. + it("pause() then shutdown() keeps a half-closed socket open while the peer is silent", async () => { + let closedEarly = false; + const opened = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(s) { + s.pause(); + s.shutdown(); + opened.resolve(); + }, + data() {}, + end() {}, + error() {}, + close() { + closedEarly = true; + }, + }, + }); + // allowHalfOpen peer ignores our FIN and stays silently connected. + const peer = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + allowHalfOpen: true, + socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, + }); + await opened.promise; + // Negative-assertion window, same rationale as the shutdown-then-pause test. + await Bun.sleep(250); + const closed = closedEarly; + peer.terminate(); + expect(closed).toBe(false); + }); + // The flip side: with the write filter unusable after our own shutdown() // (its EV_EOF echoes SS_CANTSENDMORE), the read-side teardown watch must // still deliver the peer's actual termination to a paused half-closed From cd1f89c0448c0d2fad513660e7c1e668b173e068 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:32:56 +0000 Subject: [PATCH 4/9] test: scope the peer-terminates mirror test to POSIX The libuv backend has no event for a reset against a paused 0-event poll (AFD only reports subscribed events and the DISCONNECT was consumed), so the test strands on Windows. That gap predates this change and is tracked separately; the test pins the POSIX contract this PR fixes. --- test/js/bun/net/socket.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 21e7577fc2b8..38070f74b50f 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -479,8 +479,11 @@ describe.concurrent("socket", () => { // The flip side: with the write filter unusable after our own shutdown() // (its EV_EOF echoes SS_CANTSENDMORE), the read-side teardown watch must // still deliver the peer's actual termination to a paused half-closed - // socket (epoll gets this via the implicit EPOLLHUP|EPOLLERR). - it("shutdown() then pause() still closes when the peer terminates", async () => { + // socket (epoll gets this via the implicit EPOLLHUP|EPOLLERR). Windows is + // excluded: the libuv backend has no event for a reset against a paused + // 0-event poll (AFD only reports subscribed events), a pre-existing gap + // tracked separately from this change. + it.skipIf(isWindows)("shutdown() then pause() still closes when the peer terminates", async () => { const closed = Promise.withResolvers(); const opened = Promise.withResolvers(); using server = Bun.listen({ From 27a1df9045a81d4ecfd526b3e36725c60ec97676 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:45:35 +0000 Subject: [PATCH 5/9] docs: describe the End-path read-filter cycle in its current mechanics The comments described the pre-change pause/resume interest steps (W -> R|W -> R and the undeleted write one-shot); the cycle macOS 26 needs is preserved but now runs through the shut-down teardown transition. --- .../node/JSNodeHTTPServerSocketPrototype.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index 59e3be6835e9..a20ce40b8400 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -224,15 +224,17 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketEnd, (JSC::JSGlobalObject auto bufferedSize = thisObject->streamBuffer.bufferedSize(); if (bufferedSize == 0) { // onNodeHTTPRequest no longer pauses at dispatch; pause here so the - // shutdown+resume below still cycles kqueue's EVFILT_READ (delete then - // re-add), without which macOS 26 does not deliver the peer's close. + // shutdown still cycles kqueue's EVFILT_READ, without which macOS 26 + // does not deliver the peer's close. The cycle's current mechanics: + // pause drops the read filter, the shut-down 0-event teardown + // transition (us_internal_socket_raw_shutdown) re-adds it before the + // SHUT_WR lands, and resume below makes it level-triggered again. if (thisObject->socket && !thisObject->upgraded) { us_socket_pause(thisObject->socket); } auto result = us_socket_buffered_js_write(thisObject->socket, thisObject->is_ssl, thisObject->ended, &thisObject->streamBuffer, globalObject, JSValue::encode(JSC::jsUndefined()), JSValue::encode(JSC::jsUndefined())); - // Undo the pause above after the shutdown so the unread body drains - // and kqueue's one-shot EVFILT_WRITE (which delivers EV_EOF on - // SHUT_WR) is not deleted by a W -> R|W -> R step. + // Undo the pause after the shutdown so the unread body drains; the + // teardown transition already scrubbed the stale write filter. if (thisObject->socket && !thisObject->upgraded) { us_socket_resume(thisObject->socket); } From 3604573a28d1707e3065eca3ab24951bf7203d96 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:46:51 +0000 Subject: [PATCH 6/9] docs: tighten the End-path cycle comment --- .../bindings/node/JSNodeHTTPServerSocketPrototype.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index a20ce40b8400..fb57ce879895 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -223,12 +223,10 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketEnd, (JSC::JSGlobalObject } auto bufferedSize = thisObject->streamBuffer.bufferedSize(); if (bufferedSize == 0) { - // onNodeHTTPRequest no longer pauses at dispatch; pause here so the - // shutdown still cycles kqueue's EVFILT_READ, without which macOS 26 - // does not deliver the peer's close. The cycle's current mechanics: - // pause drops the read filter, the shut-down 0-event teardown - // transition (us_internal_socket_raw_shutdown) re-adds it before the - // SHUT_WR lands, and resume below makes it level-triggered again. + // Pause so the shutdown cycles kqueue's EVFILT_READ (pause drops it; + // us_internal_socket_raw_shutdown's teardown transition re-adds it + // before SHUT_WR), without which macOS 26 does not deliver the + // peer's close. if (thisObject->socket && !thisObject->upgraded) { us_socket_pause(thisObject->socket); } From 6206cde3fc9092e0d8eb6d17d6aad59d1d189d6e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:13:09 +0000 Subject: [PATCH 7/9] usockets: arm the teardown watch after shutdown(2); scrub phantoms on every shutdown; share the writable-pending helper macOS 26 only delivers a peer's close on a read filter registered after SHUT_WR: the teardown watch armed before the shutdown syscall made node-http-halfclose-midupload time out at connection-closed on the darwin 26 lanes (the End path's previous delete-then-re-add cycle re-added after). The watch now arms after shutdown(2); EV_EOF is level state, so a FIN landing in the gap is still reported by the fresh registration. Review catches, same class: - pause() then resume() then shutdown() left the 0-event fallback's phantom write one-shot armed across SHUT_WR (resume no longer records writable, so neither diff saw it). The still-reading shutdown path now scrubs EVFILT_WRITE explicitly; new silent-peer test pins the third ordering. - us_socket_sendfile_needs_more was the pre-existing sibling of the new helper with the old force-READABLE semantics; it now routes through us_socket_mark_writable_pending (respects pause and shut-down). --- packages/bun-usockets/src/socket.c | 17 +++++++++++-- src/uws_sys/libuwsockets.cpp | 7 +++--- test/js/bun/net/socket.test.ts | 39 ++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 9eccfce28c89..2e3d6ca53928 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -720,16 +720,29 @@ void us_internal_socket_raw_shutdown(struct us_socket_t *s) { if (!us_socket_is_closed(s) && us_internal_poll_type(&s->p) != POLL_TYPE_SOCKET_SHUT_DOWN) { us_internal_poll_set_type(&s->p, POLL_TYPE_SOCKET_SHUT_DOWN); us_poll_change(&s->p, s->group->loop, us_poll_events(&s->p) & LIBUS_SOCKET_READABLE); + bsd_shutdown_socket(us_poll_fd((struct us_poll_t *) s)); #ifdef LIBUS_USE_KQUEUE /* A socket already at 0 events (paused with nothing buffered) diffs * to a no-op above, leaving no kqueue filter at all; arm the * read-side teardown watch directly so the peer's FIN/RST still - * closes us (epoll's implicit EPOLLHUP|EPOLLERR needs no filter). */ + * closes us (epoll's implicit EPOLLHUP|EPOLLERR needs no filter). + * AFTER the shutdown(2): macOS 26 only delivers the peer's close on + * a filter registered after SHUT_WR (node-http-halfclose-midupload + * timed out with the watch armed before it), and EV_EOF is level + * state, so a FIN landing in the gap is still reported by the fresh + * registration. */ if (us_poll_events(&s->p) == 0) { kqueue_change(s->group->loop->fd, us_poll_fd(&s->p), 0, 0, &s->p); + } else { + /* Still reading: scrub any phantom write one-shot the 0-event + * fallback armed during an earlier pause (poll_events never + * records it, so the diff above cannot see it; ENOENT is + * harmless when none exists). */ + kqueue_change(s->group->loop->fd, us_poll_fd(&s->p), + LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE, + LIBUS_SOCKET_READABLE, &s->p); } #endif - bsd_shutdown_socket(us_poll_fd((struct us_poll_t *) s)); } } diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 989380a317b1..456dcb2e2c62 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -2007,9 +2007,10 @@ __attribute__((callback (corker, ctx))) } void us_socket_sendfile_needs_more(us_socket_r s) { - if(us_socket_is_closed(s)) return; - s->flags.last_write_failed = 1; - us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); + /* Same job as the pipelined-response park sites: bytes (the file tail) + * held outside the socket's write path need a writable event. The shared + * helper respects pause and shut-down instead of forcing READABLE. */ + us_socket_mark_writable_pending(s); } LIBUS_SOCKET_DESCRIPTOR us_socket_get_fd(us_socket_r s) { diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 38070f74b50f..f674ca6d45fd 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -476,6 +476,45 @@ describe.concurrent("socket", () => { expect(closed).toBe(false); }); + // Third ordering: pause() then resume() leaves the 0-event fallback's + // write one-shot armed but unrecorded; shutdown() must scrub it or it + // echoes our own SS_CANTSENDMORE. + it("pause() then resume() then shutdown() keeps a half-closed socket open while the peer is silent", async () => { + let closedEarly = false; + const opened = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(s) { + s.pause(); + s.resume(); + s.shutdown(); + opened.resolve(); + }, + data() {}, + end() {}, + error() {}, + close() { + closedEarly = true; + }, + }, + }); + // allowHalfOpen peer ignores our FIN and stays silently connected. + const peer = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + allowHalfOpen: true, + socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, + }); + await opened.promise; + // Negative-assertion window, same rationale as the shutdown-then-pause test. + await Bun.sleep(250); + const closed = closedEarly; + peer.terminate(); + expect(closed).toBe(false); + }); + // The flip side: with the write filter unusable after our own shutdown() // (its EV_EOF echoes SS_CANTSENDMORE), the read-side teardown watch must // still deliver the peer's actual termination to a paused half-closed From 67b6c5cdba8821c7d01aeb2d60ab60121b35b96e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:19:41 +0000 Subject: [PATCH 8/9] test: replace negative-assertion sleeps with event-loop cycle checkpoints An echo round trip through an independent pair on the same loop cannot complete before already-ready events for other sockets have dispatched, so N round trips prove N full poll cycles ran: the buggy drain/close would have fired inside the first. Deterministic and faster than the fixed windows. Also trims the sendfile wrapper comment. --- src/uws_sys/libuwsockets.cpp | 5 ++- test/js/bun/net/socket.test.ts | 61 ++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 456dcb2e2c62..66ed791f557d 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -2007,9 +2007,8 @@ __attribute__((callback (corker, ctx))) } void us_socket_sendfile_needs_more(us_socket_r s) { - /* Same job as the pipelined-response park sites: bytes (the file tail) - * held outside the socket's write path need a writable event. The shared - * helper respects pause and shut-down instead of forcing READABLE. */ + /* The pending file tail lives outside the socket's write path; see + * us_socket_mark_writable_pending in libusockets.h. */ us_socket_mark_writable_pending(s); } diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index f674ca6d45fd..d55145891818 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -362,6 +362,47 @@ describe.concurrent("socket", () => { expect(await bunRun(fileURLToPath(new URL("./kqueue-filter-coalesce-fixture.ts", import.meta.url)))).toSpawn(); }); + // Deterministic checkpoint for the negative assertions below: an echo round + // trip through an independent pair on the SAME event loop cannot complete + // before events that were already ready for other sockets have dispatched, + // so N round trips prove N full poll cycles ran. + async function loopCycles(n: number) { + using echo = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open() {}, + data(s, d) { + s.write(d); + }, + end() {}, + error() {}, + close() {}, + }, + }); + const done = Promise.withResolvers(); + let count = 0; + const opened = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: echo.port, + socket: { + open: s => opened.resolve(s), + data(s) { + if (++count >= n) done.resolve(); + else s.write("p"); + }, + end() {}, + error() {}, + close() {}, + }, + }); + const probe = await opened.promise; + probe.write("p"); + await done.promise; + probe.terminate(); + } + // us_socket_pause armed WRITABLE unconditionally; the always-writable socket // then dispatched a bogus drain with nothing buffered. it("pause() with nothing buffered must not fire a drain event", async () => { @@ -387,12 +428,12 @@ describe.concurrent("socket", () => { }, }); const s = await opened.promise; - await Bun.sleep(50); // let any connect-time writable settle + await loopCycles(2); // any connect-time writable has dispatched const before = drains; s.pause(); - await Bun.sleep(100); // window in which the buggy pause-armed writable fired + await loopCycles(3); // the buggy pause-armed writable would have fired s.resume(); - await Bun.sleep(100); // resume() manufacturing WRITABLE fired one too + await loopCycles(3); // resume() manufacturing WRITABLE would have too const delta = drains - before; s.terminate(); // release before asserting so a failure does not leak the socket expect(delta).toBe(0); @@ -430,9 +471,9 @@ describe.concurrent("socket", () => { socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, }); await opened.promise; - // Negative-assertion window: the buggy kqueue EV_EOF closed within ~2ms, - // so 250ms is >100x margin without spending the whole per-test budget. - await Bun.sleep(250); + // The buggy kqueue EV_EOF close dispatched in the first poll cycle after + // the pause; several full cycles prove it is not coming. + await loopCycles(3); const closed = closedEarly; peer.terminate(); // release before asserting so a failure does not leak the socket expect(closed).toBe(false); @@ -469,8 +510,8 @@ describe.concurrent("socket", () => { socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, }); await opened.promise; - // Negative-assertion window, same rationale as the shutdown-then-pause test. - await Bun.sleep(250); + // Same checkpoint rationale as the shutdown-then-pause test. + await loopCycles(3); const closed = closedEarly; peer.terminate(); expect(closed).toBe(false); @@ -508,8 +549,8 @@ describe.concurrent("socket", () => { socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, }); await opened.promise; - // Negative-assertion window, same rationale as the shutdown-then-pause test. - await Bun.sleep(250); + // Same checkpoint rationale as the shutdown-then-pause test. + await loopCycles(3); const closed = closedEarly; peer.terminate(); expect(closed).toBe(false); From 8d7a63eed3e6b1228712ed5bfae2b17ba303ce6e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:27:00 +0000 Subject: [PATCH 9/9] usockets: route the last needs-more sibling through the shared helper; fix a stale ordering comment us_socket_mark_needs_more_not_ssl was the remaining open-coded copy of the pre-fix pattern (forces READABLE, no shut-down guard) with a live caller in the file-response stream; it now delegates to us_socket_mark_writable_pending like its sendfile twin. The End-path comment also still said the teardown watch arms before SHUT_WR after 6206cde3 moved it after; the ordering claim now defers to socket.c. --- src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp | 6 +++--- src/uws_sys/libuwsockets.cpp | 6 +----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index fb57ce879895..fb5cd79f3f94 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -224,9 +224,9 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketEnd, (JSC::JSGlobalObject auto bufferedSize = thisObject->streamBuffer.bufferedSize(); if (bufferedSize == 0) { // Pause so the shutdown cycles kqueue's EVFILT_READ (pause drops it; - // us_internal_socket_raw_shutdown's teardown transition re-adds it - // before SHUT_WR), without which macOS 26 does not deliver the - // peer's close. + // us_internal_socket_raw_shutdown's teardown transition re-adds it, + // ordering documented there), without which macOS 26 does not + // deliver the peer's close. if (thisObject->socket && !thisObject->upgraded) { us_socket_pause(thisObject->socket); } diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 66ed791f557d..8419c35fb0fe 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -1834,11 +1834,7 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, void us_socket_mark_needs_more_not_ssl(uws_res_r res) { - us_socket_r s = (us_socket_t *)res; - if(us_socket_is_closed(s)) return; - s->flags.last_write_failed = 1; - us_poll_change(&s->p, s->group->loop, - LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); + us_socket_mark_writable_pending((us_socket_t *)res); } void uws_res_override_write_offset(int ssl, uws_res_r res, uint64_t offset)