diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 68cda0935554..1aa930582c67 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -314,7 +314,7 @@ struct us_socket_t { * the driver's epilogue via ssl_pending_detach. */ unsigned char ssl_in_use : 1; unsigned char ssl_pending_detach : 1; - /* Peer FIN was dispatched as on_end on a half-open socket; readable interest is never re-added and on_end never re-fires. */ + /* Peer FIN was dispatched as on_end on a half-open socket; on_end never re-fires (readable is only re-armed once shut down, to close). */ unsigned char read_eof : 1; /* The close code passed to the deferred close (e.g. a reset requested from * inside a handshake callback must still RST, not FIN, when it is finally diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index d2db3d7fbdf6..458ad64167fd 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -724,12 +724,11 @@ int us_connecting_socket_is_shut_down(struct us_connecting_socket_t *c) { } void us_internal_socket_raw_shutdown(struct us_socket_t *s) { - /* Todo: should we emit on_close if calling shutdown on an already half-closed socket? - * We need more states in that case, we need to track RECEIVED_FIN - * so far, the app has to track this and call close as needed */ 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); + /* After read_eof the poll may be at 0 events; re-arm READABLE so the SHUT_DOWN eof branch closes us. */ + us_poll_change(&s->p, s->group->loop, + s->read_eof ? LIBUS_SOCKET_READABLE : (us_poll_events(&s->p) & LIBUS_SOCKET_READABLE)); bsd_shutdown_socket(us_poll_fd((struct us_poll_t *) s)); #ifdef LIBUS_USE_KQUEUE if (!(us_poll_events(&s->p) & LIBUS_SOCKET_READABLE)) { diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 58b7c760ff81..9fd700243ba5 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -3827,3 +3827,59 @@ describe("allowHalfOpen socket whose peer resets behind pending writes", () => { expect(endCount).toBe(1); }); }); + +describe("allowHalfOpen socket shut down after the peer's FIN", () => { + // Once end() has been delivered the poll drops readable interest and, with + // nothing queued, settles with no events armed. shutdown() must re-arm it so + // the fully-closed state is delivered; unfixed, the libuv backend stranded. + it("closes when shutdown() runs after the poll has settled", async () => { + const ended = Promise.withResolvers(); + const drained = Promise.withResolvers(); + const closed = Promise.withResolvers(); + + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + allowHalfOpen: true, + socket: { + open() {}, + data() {}, + end: s => ended.resolve(s), + // The EOF path arms one writable wakeup; this is the last event the + // socket emits before it goes idle. + drain: () => drained.resolve(), + error(_s, e) { + ended.reject(e); + drained.reject(e); + closed.reject(e); + }, + close: () => closed.resolve(), + }, + }); + + const peerClosed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + allowHalfOpen: true, + socket: { + open: s => s.shutdown(), // FIN + data() {}, + end() {}, + error: (_s, e) => peerClosed.reject(e), + close: () => peerClosed.resolve(), + }, + }); + + const victim = await ended.promise; + await drained.promise; + // Going idle is not observable from JS: after drain returns, usockets drops + // the poll's events, and the event loop's next poll phase then consumes the + // FIN's final re-report. Two loop turns get past that poll phase; shutting + // down any earlier still finds an armed wakeup and passes without the fix. + for (let i = 0; i < 2; i++) await new Promise(r => setImmediate(r)); + victim.shutdown(); + await closed.promise; + await peerClosed.promise; + }); +});