diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 3c9c54a1c18b..4837689d12e7 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -288,6 +288,8 @@ pub struct NewServer { pub(crate) all_closed_promise: jsc::JSPromiseStrong, pub poll_ref: KeepAlive, + /// Ref'd while `pending_requests > 0` so `server.unref()` doesn't drop in-flight requests. + in_flight_keep_alive: KeepAlive, pub(crate) flags: ServerFlags, @@ -486,6 +488,11 @@ impl NewServer { } pub(crate) fn on_pending_request(&mut self) { + if self.pending_requests == 0 { + // SAFETY: `self.vm` is the live per-thread VM singleton backref. + self.in_flight_keep_alive + .ref_(unsafe { jsc::VirtualMachine::event_loop_ctx(self.vm.as_ptr()) }); + } self.pending_requests += 1; } @@ -1486,6 +1493,11 @@ impl NewServer { #[inline] pub(crate) fn on_request_complete(&mut self) { self.pending_requests -= 1; + if self.pending_requests == 0 { + // SAFETY: `self.vm` is the live per-thread VM singleton backref. + self.in_flight_keep_alive + .unref(unsafe { jsc::VirtualMachine::event_loop_ctx(self.vm.as_ptr()) }); + } self.deinit_if_we_can(); } @@ -1596,6 +1608,7 @@ impl NewServer { self.unref(); self.notify_inspector_server_stopped(); if abrupt { + self.in_flight_keep_alive.disable(); self.flags.insert(ServerFlags::TERMINATED); } } @@ -1604,6 +1617,9 @@ impl NewServer { if abrupt || (self.pending_requests == 0 && !self.has_active_web_sockets()) { self.unref(); } + if abrupt { + self.in_flight_keep_alive.disable(); + } // A graceful stop with work in flight keeps the ref (deinit_if_we_can // unrefs when the drain completes): on Windows uv_run skips I/O with // zero ref'd handles, so unrefing here wedged server.close() teardown. @@ -2031,6 +2047,7 @@ impl NewServer { h3_request_pool: core::ptr::null_mut(), all_closed_promise: jsc::JSPromiseStrong::default(), poll_ref: KeepAlive::default(), + in_flight_keep_alive: KeepAlive::default(), flags: ServerFlags::default(), plugins: None, user_routes: Vec::new(), diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 40d9c43d2cf7..e9b3a22a0b33 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3422,7 +3422,7 @@ where resp.end_without_body(true); return; } - this.pending_requests += 1; + this.on_pending_request(); req.set_yield(false); // SAFETY: `request_pool` is non-null while the server is alive; `claim()` // reserves a fresh slot whose `Drop` releases it on panic before init. diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 5f6adb936c3b..0598380f0d22 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -497,6 +497,36 @@ test("unref keeps process alive for ongoing connections", async () => { expect(await bunRun(path.join(import.meta.dir, "unref-fixture-2.ts"))).toSpawn(); }); +test("unref: an in-flight request keeps the process alive on its own", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "unref-fixture-3.ts")], + env: bunEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stderrPromise = proc.stderr.text(); + const exitedPromise = proc.exited; + + // Read the port line while the subprocess keeps running. + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (!buf.includes("\n")) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + } + reader.releaseLock(); + const port = Number(buf.trim()); + expect(port).toBeGreaterThan(0); + + const res = await fetch(`http://127.0.0.1:${port}/`); + const body = await res.text(); + const [stderr, exitCode] = await Promise.all([stderrPromise, exitedPromise]); + expect({ body, stderr, exitCode }).toEqual({ body: "ok", stderr: "", exitCode: 0 }); +}); + test("Bun does not crash when given invalid config", async () => { await using server1 = Bun.serve({ fetch(request, server) { diff --git a/test/js/bun/http/unref-fixture-3.ts b/test/js/bun/http/unref-fixture-3.ts new file mode 100644 index 000000000000..b52128e18e7a --- /dev/null +++ b/test/js/bun/http/unref-fixture-3.ts @@ -0,0 +1,13 @@ +// The server starts ref'd so the process lives until the request arrives. +// Inside the handler we unref the server and await an unref'd timer, so the +// only thing keeping this process alive while the handler is suspended is the +// in-flight request itself. The client lives in the parent process. +const server = Bun.serve({ + port: 0, + async fetch() { + server.unref(); + await new Promise(resolve => setTimeout(resolve, 200).unref()); + return new Response("ok"); + }, +}); +process.stdout.write(String(server.port) + "\n");