diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 11efd18008e5..63c8f3f99df0 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -647,31 +647,9 @@ pub(crate) fn wrap_handler_slot( } impl NewServer { - /// Per-monomorphization static. - /// Rust statics cannot be const-generic; routed through a - /// `&'static AtomicBool` so the four (SSL,DEBUG) instantiations share one - /// flag — the warning is process-global by intent (printed at most once - /// regardless of how many servers are running). - fn did_send_idletimeout_warning_once() -> &'static core::sync::atomic::AtomicBool { - static FLAG: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); - &FLAG - } - - /// The body ignores both arguments so a single non-generic shim suffices. - fn on_timeout_for_idle_warn(_: *mut c_void, _: &mut uws_sys::NewAppResponse) { - if DEBUG && !Self::did_send_idletimeout_warning_once().load(Ordering::Relaxed) { - if !crate::cli::Command::get().debug.silent { - Self::did_send_idletimeout_warning_once().store(true, Ordering::Relaxed); - bun_core::warn!( - "Bun.serve() timed out a request after 10 seconds. Pass `idleTimeout` to configure." - ); - } - } - } - fn should_add_timeout_handler_for_warning(&self) -> bool { if DEBUG { - if !Self::did_send_idletimeout_warning_once().load(Ordering::Relaxed) + if !server_body::did_send_idletimeout_warning_once().load(Ordering::Relaxed) && !crate::cli::Command::get().debug.silent { return !self.config.has_idle_timeout; @@ -759,8 +737,10 @@ impl NewServer { if server.should_add_timeout_handler_for_warning() { // We need to pass it a pointer, any pointer should do. resp_ref.on_timeout( - Self::on_timeout_for_idle_warn, - Self::did_send_idletimeout_warning_once() + |_: *mut c_void, _: &mut uws_sys::NewAppResponse| { + server_body::on_timeout_for_idle_warn() + }, + server_body::did_send_idletimeout_warning_once() .as_ptr() .cast::(), ); diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index aa960c0c256e..ec4d69a9395a 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -1203,24 +1203,20 @@ fn fetch_headers_from_js(value: JSValue, global: &JSGlobalObject) -> Option<*mut FetchHeaders::cast_(value, global.vm()).map(|p| p.as_ptr()) } -/// Per-process latch for the dev-mode idle-timeout warning. The -/// warning is gated on `DEBUG && !silent` and only fires once globally, so a -/// single shared `AtomicBool` matches user-visible behavior. +/// Process-wide once latch for the dev-mode idle-timeout warning, shared by all protocols. #[inline] -fn did_send_idletimeout_warning_once() -> &'static core::sync::atomic::AtomicBool { +pub(super) fn did_send_idletimeout_warning_once() -> &'static core::sync::atomic::AtomicBool { static FLAG: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); &FLAG } -/// Emits the once-only dev-mode -/// warning. Factored out as a free fn so the `RespLike::on_timeout_warn` -/// closures (which cannot name `NewServer`) can call it. -fn on_timeout_for_idle_warn() { +/// The one idle-timeout warning handler for HTTP/1, HTTP/2 and HTTP/3. +pub(super) fn on_timeout_for_idle_warn() { if !did_send_idletimeout_warning_once().swap(true, core::sync::atomic::Ordering::Relaxed) && !crate::cli::Command::get().debug.silent { - bun_core::pretty_errorln!( - "[Bun.serve]: request timed out after 10 seconds. Pass `idleTimeout` to configure." + bun_core::warn!( + "Bun.serve() timed out a request after 10 seconds. Pass `idleTimeout` to configure." ); Output::flush(); } diff --git a/test/regression/issue/43016.test.ts b/test/regression/issue/43016.test.ts new file mode 100644 index 000000000000..8128b9b827c0 --- /dev/null +++ b/test/regression/issue/43016.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// https://github.com/oven-sh/bun/issues/43016 +// The dev-mode idle timeout warning is meant to print once per process. The +// HTTP/1 path and the HTTP/2 and HTTP/3 path each had their own once flag and +// their own text, so a process that timed out one request on each protocol +// printed the warning twice. +// +// The warning is only registered when `idleTimeout` is not passed, so the +// test has to wait for the default 10 second timeout on both connections. +// That is longer than the default per-test timeout, so this test sets one. +test("Bun.serve prints the idle timeout warning once for HTTP/1 and HTTP/2 timeouts", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import http2 from "node:http2"; + import net from "node:net"; + + const seen = []; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: true, + http2: true, + async fetch(req) { + seen.push(req.headers.get("x-proto")); + await req.text(); + return new Response("ok"); + }, + }); + + // The server closes each connection when its idle timeout fires. The + // request count proves that both requests reached the handler before + // that, so a client that failed early cannot pass as a timeout. + let remaining = 2; + function done() { + if (--remaining === 0) { + console.log("both closed, requests seen: " + seen.sort().join(",")); + process.exit(0); + } + } + + // HTTP/1: a POST whose body never completes. + const h1 = net.connect(server.port, "127.0.0.1", () => { + h1.write("POST / HTTP/1.1\\r\\nHost: x\\r\\nX-Proto: h1\\r\\nContent-Length: 100\\r\\n\\r\\nabc"); + }); + h1.on("error", () => {}); + h1.on("close", done); + + // HTTP/2 (cleartext, prior knowledge): a POST stream that never ends. + const session = http2.connect("http://127.0.0.1:" + server.port); + session.on("error", () => {}); + session.on("close", done); + const stream = session.request({ ":method": "POST", ":path": "/", "x-proto": "h2" }); + stream.on("error", () => {}); + stream.write("abc"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("both closed, requests seen: h1,h2\n"); + const warning = "warn: Bun.serve() timed out a request after 10 seconds. Pass `idleTimeout` to configure."; + expect(stderr.split(warning).length - 1).toBe(1); + expect(stderr).not.toContain("[Bun.serve]: request timed out"); + expect(exitCode).toBe(0); +}, 30_000);