From b16485a5967741efe3acaf7a071350cf4413ac57 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:44:37 +0000 Subject: [PATCH 1/3] Bun.listen: keep the unref'd listener wrapper alive while it is still listening Listener::unref downgraded this_value to weak while the listen socket was still open. With no other references, GC collected the wrapper and the JSSocketHandlers cell it roots, while the socket kept accepting: the next accepted connection dispatched on_open into the swept cell (SIGABRT or type confusion), and once the finalizer had closed the socket the port refused connections that Node semantics say it must still serve. unref() now only releases the event-loop ref, matching the this_value contract (strong while listening or with connections). The idle downgrade stays where it already lives: do_stop and Handlers::mark_inactive, both gated on the listen socket being closed. --- src/runtime/socket/Listener.rs | 8 +++-- test/js/bun/net/socket.test.ts | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 920bdf75ac55..54b7e5ab6247 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -1012,9 +1012,11 @@ impl Listener { _frame: &CallFrame, ) -> JsResult { this.poll_ref.with_mut(|p| p.unref(bun_io::js_vm_ctx())); - if this.handlers.active_connections.get() == 0 { - this.this_value.with_mut(|r| r.downgrade()); - } + // No `this_value` downgrade here: the socket is still listening, and a + // dispatched accept reads the handlers cell that only the wrapper's + // `handlers` slot roots — collecting the wrapper frees the callbacks + // out from under the open socket (and libuv/Node keep unref'd servers + // accepting). `do_stop` / `mark_inactive` downgrade once it's closed. Ok(JSValue::UNDEFINED) } diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 37529ade271e..72d77c66b8d7 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -3333,3 +3333,62 @@ describe("TLS handshake callback throw", () => { } }); }); + +it("an unref'd Bun.listen() with no other references keeps accepting across GC", async () => { + // Listener.unref() used to downgrade the wrapper's GC ref to weak while the + // socket was still listening. GC then collected the wrapper (and the + // handlers cell it roots), so the next accepted connection either dispatched + // on_open into a swept cell (SIGABRT / type confusion) or, once the + // finalizer had closed the socket, got ECONNREFUSED. unref() must only + // release the event-loop hold; the wrapper stays reachable until stop(). + const src = ` + const churnSink = []; + function churn() { + let a = []; + for (let j = 0; j < 30000; j++) a.push(j & 1 ? { j } : "s" + j); + churnSink[0] = a; + for (let j = 0; j < 30000; j++) churnSink[1] = function () { return j; }; + } + function makeUnreffedListener() { + const l = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { + open(s) { + s.write("HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nok"); + s.end(); + }, + data() {}, + error() {}, + }}); + const port = l.port; + l.unref(); + return port; + } + const ports = []; + for (let round = 0; round < 4; round++) { + ports.push(makeUnreffedListener()); + churn(); + Bun.gc(true); + churn(); + await Bun.sleep(1); + Bun.gc(true); + for (const port of ports) { + const text = await fetch("http://127.0.0.1:" + port + "/").then(r => r.text()); + if (text !== "ok") throw new Error("listener " + port + " replied " + JSON.stringify(text)); + } + } + console.log("served " + ports.length + " listeners"); + `; + // The subprocess must also exit on its own: unref() still has to release + // the listeners' event-loop refs even though their wrappers stay reachable. + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "served 4 listeners", + stderr: "", + exitCode: 0, + }); +}); From db315a0e2d91830fed529a39dea952813ee8d7c9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:59:11 +0000 Subject: [PATCH 2/3] test: use a raw TCP client in the unref'd-listener GC test The HTTP shape raced on Windows: the server replies and ends without reading the request, and closing with the request bytes unread resets the connection, so fetch saw ECONNRESET. A Bun.connect client that sends nothing has no unread data to reset on. --- test/js/bun/net/socket.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 72d77c66b8d7..7cfc7718ca80 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -3341,6 +3341,8 @@ it("an unref'd Bun.listen() with no other references keeps accepting across GC", // on_open into a swept cell (SIGABRT / type confusion) or, once the // finalizer had closed the socket, got ECONNREFUSED. unref() must only // release the event-loop hold; the wrapper stays reachable until stop(). + // Raw TCP instead of fetch: the server replies and ends without reading the + // request, and on Windows closing with the request unread RSTs the exchange. const src = ` const churnSink = []; function churn() { @@ -3352,7 +3354,7 @@ it("an unref'd Bun.listen() with no other references keeps accepting across GC", function makeUnreffedListener() { const l = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { open(s) { - s.write("HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nok"); + s.write("ok"); s.end(); }, data() {}, @@ -3362,6 +3364,16 @@ it("an unref'd Bun.listen() with no other references keeps accepting across GC", l.unref(); return port; } + async function roundTrip(port) { + const { promise, resolve, reject } = Promise.withResolvers(); + let received = ""; + await Bun.connect({ hostname: "127.0.0.1", port, socket: { + data(s, d) { received += d; }, + close() { resolve(received); }, + error(s, e) { reject(e); }, + }}); + return promise; + } const ports = []; for (let round = 0; round < 4; round++) { ports.push(makeUnreffedListener()); @@ -3371,8 +3383,8 @@ it("an unref'd Bun.listen() with no other references keeps accepting across GC", await Bun.sleep(1); Bun.gc(true); for (const port of ports) { - const text = await fetch("http://127.0.0.1:" + port + "/").then(r => r.text()); - if (text !== "ok") throw new Error("listener " + port + " replied " + JSON.stringify(text)); + const got = await roundTrip(port); + if (got !== "ok") throw new Error("listener " + port + " replied " + JSON.stringify(got)); } } console.log("served " + ports.length + " listeners"); From 5bd46d3c86e3df8e54aabf2c07cd4008cfc8d72f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:07:49 +0000 Subject: [PATCH 3/3] Trim the unref comment --- src/runtime/socket/Listener.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 54b7e5ab6247..8164c943e028 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -1012,11 +1012,9 @@ impl Listener { _frame: &CallFrame, ) -> JsResult { this.poll_ref.with_mut(|p| p.unref(bun_io::js_vm_ctx())); - // No `this_value` downgrade here: the socket is still listening, and a - // dispatched accept reads the handlers cell that only the wrapper's - // `handlers` slot roots — collecting the wrapper frees the callbacks - // out from under the open socket (and libuv/Node keep unref'd servers - // accepting). `do_stop` / `mark_inactive` downgrade once it's closed. + // `this_value` stays strong: the wrapper roots the handlers a future + // accept dispatches into. `do_stop` / `mark_inactive` downgrade it + // once the listen socket is closed. Ok(JSValue::UNDEFINED) }