From 33d16f54d77c5822fbe7cdc013629bd29945dd86 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:58:44 +0000 Subject: [PATCH 1/3] Bun.serve: make stop(true) force-close after a prior graceful stop server.stop(false) takes the listener and closes the listen socket while leaving in-flight connections draining. A subsequent server.stop(true) then hit the has_listener() guard in stop_from_js and did nothing; even without the guard, stop_listening(true) early-returned because the listener was already gone. SSE/streaming responses kept flowing, the ReadableStream cancel() and req.signal never fired, and pendingRequests never drained, so the usual graceful-then-deadline shutdown pattern could not force anything closed. stop_from_js / dispose_from_js now also call stop() when the listener is gone but the app has not been terminated, and stop_listening closes the app on an abrupt stop even if the listener was taken by an earlier graceful stop. --- src/runtime/server/mod.rs | 13 ++++- src/runtime/server/server_body.rs | 18 +++---- test/js/bun/http/serve.test.ts | 86 +++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 1b0f1fb48c43..bf55ab428901 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1522,8 +1522,17 @@ impl NewServer { if Self::HAS_H3 && self.h3_app.is_some() { self.unref(); self.notify_inspector_server_stopped(); - if abrupt { - self.flags.insert(ServerFlags::TERMINATED); + } + // A previous graceful stop already took the listener. An abrupt stop + // still needs to close the app so in-flight connections are torn down. + if abrupt && !self.flags.contains(ServerFlags::TERMINATED) { + if let Some(ws) = self.config.websocket.as_mut() { + ws.handler.app = None; + } + self.flags.insert(ServerFlags::TERMINATED); + if let Some(app) = self.app { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app).close(); } } return; diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index adcbbdf1ee19..17ac382fe15b 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -2502,15 +2502,15 @@ where pub fn stop_from_js(&mut self, abruptly: Option) -> JSValue { let rc = self.get_all_closed_promise(&self.global()); - if self.has_listener() { - let abrupt = 'brk: { - if let Some(val) = abruptly { - if val.is_boolean() && val.to_boolean() { - break 'brk true; - } + let abrupt = 'brk: { + if let Some(val) = abruptly { + if val.is_boolean() && val.to_boolean() { + break 'brk true; } - false - }; + } + false + }; + if self.has_listener() || (abrupt && !self.flags.contains(ServerFlags::TERMINATED)) { self.stop(abrupt); } @@ -2518,7 +2518,7 @@ where } pub fn dispose_from_js(&mut self) -> JSValue { - if self.has_listener() { + if self.has_listener() || !self.flags.contains(ServerFlags::TERMINATED) { self.stop(true); } JSValue::UNDEFINED diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 8f8d3de72e5b..6c54f95b4dd0 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -2196,6 +2196,92 @@ it("should be able to abrupt stop the server", async () => { } }); +describe("server.stop(true) after a prior graceful stop", () => { + async function setup(stopper: (server: Server) => void) { + const enc = new TextEncoder(); + const firstChunk = Promise.withResolvers(); + const aborted = mock(() => {}); + const cancelled = mock(() => {}); + const server = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch(req) { + req.signal.addEventListener("abort", aborted); + return new Response( + new ReadableStream({ + async pull(controller) { + controller.enqueue(enc.encode("data: x\n\n")); + await Bun.sleep(20); + }, + cancel: cancelled, + }), + { headers: { "Content-Type": "text/event-stream" } }, + ); + }, + }); + const closed = Promise.withResolvers(); + const sock = net.connect(server.port, "127.0.0.1", () => { + sock.write(`GET /sse HTTP/1.1\r\nHost: x\r\n\r\n`); + }); + sock.once("data", () => firstChunk.resolve()); + sock.on("error", () => {}); + sock.on("close", () => closed.resolve()); + try { + await firstChunk.promise; + + server.stop(false); + expect(server.pendingRequests).toBe(1); + + stopper(server); + await closed.promise; + + expect({ + pendingRequests: server.pendingRequests, + cancelled: cancelled.mock.calls.length, + aborted: aborted.mock.calls.length, + }).toEqual({ pendingRequests: 0, cancelled: 1, aborted: 1 }); + } finally { + sock.destroy(); + server.stop(true); + } + } + + it("force-closes in-flight connections", async () => { + await setup(server => server.stop(true)); + }); + + it("force-closes in-flight connections via [Symbol.dispose]", async () => { + await setup(server => server[Symbol.dispose]()); + }); + + it("resolves the stop() promise once connections are gone", async () => { + const server = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch() { + return new Response(new ReadableStream({ pull: () => Bun.sleep(1000) })); + }, + }); + const gotData = Promise.withResolvers(); + const sock = net.connect(server.port, "127.0.0.1", () => { + sock.write(`GET / HTTP/1.1\r\nHost: x\r\n\r\n`); + }); + sock.once("data", () => gotData.resolve()); + sock.on("error", () => {}); + try { + await gotData.promise; + const gracefulPromise = server.stop(false); + expect(server.pendingRequests).toBe(1); + const forcePromise = server.stop(true); + await Promise.all([gracefulPromise, forcePromise]); + expect(server.pendingRequests).toBe(0); + } finally { + sock.destroy(); + server.stop(true); + } + }); +}); + it.concurrent("should not instanciate error instances in each request", async () => { const startErrorCount = heapStats().objectTypeCounts.Error || 0; using server = Bun.serve({ From 07458b08dcdd2cd41cd4e9cf61da3fd7002660e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:23:01 +0000 Subject: [PATCH 2/3] Extract the abrupt-close path into terminate_app() Deduplicates the two stop_listening branches that clear the websocket handler, set TERMINATED and close the uws app, and replaces the self.app.unwrap() with an if-let so both call sites agree. --- src/runtime/server/mod.rs | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index bf55ab428901..4f329de7bb53 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1525,15 +1525,8 @@ impl NewServer { } // A previous graceful stop already took the listener. An abrupt stop // still needs to close the app so in-flight connections are torn down. - if abrupt && !self.flags.contains(ServerFlags::TERMINATED) { - if let Some(ws) = self.config.websocket.as_mut() { - ws.handler.app = None; - } - self.flags.insert(ServerFlags::TERMINATED); - if let Some(app) = self.app { - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app).close(); - } + if abrupt { + self.terminate_app(); } return; }; @@ -1561,13 +1554,24 @@ impl NewServer { if !abrupt { // S012: `app::ListenSocket` is a ZST opaque — safe deref. bun_opaque::opaque_deref_mut(listener).close(); - } else if !self.flags.contains(ServerFlags::TERMINATED) { - if let Some(ws) = self.config.websocket.as_mut() { - ws.handler.app = None; - } - self.flags.insert(ServerFlags::TERMINATED); + } else { + self.terminate_app(); + } + } + + /// Force-close every connection on the uws app and mark the server + /// terminated. Guarded by `TERMINATED` so repeated abrupt stops are no-ops. + fn terminate_app(&mut self) { + if self.flags.contains(ServerFlags::TERMINATED) { + return; + } + if let Some(ws) = self.config.websocket.as_mut() { + ws.handler.app = None; + } + self.flags.insert(ServerFlags::TERMINATED); + if let Some(app) = self.app { // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(self.app.unwrap()).close(); + bun_opaque::opaque_deref_mut(app).close(); } } From 831d8a68cf76f3ec64cafb65d0c521590d37d1a5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:46:24 +0000 Subject: [PATCH 3/3] ci: retrigger