diff --git a/cli/worker.rs b/cli/worker.rs index 1520cc07f79b63..7cbff3832beb5a 100644 --- a/cli/worker.rs +++ b/cli/worker.rs @@ -73,6 +73,23 @@ impl CliMainWorker { let mut maybe_coverage_collector = self.maybe_setup_coverage_collector(); let mut maybe_hmr_runner = self.maybe_setup_hmr_runner(); + // Coverage collection requires flushing data via the V8 inspector before + // the process exits. Normally this happens through the graceful shutdown + // path (after the event loop ends), but when an external process manager + // (e.g. Playwright's webServer) sends SIGTERM/SIGINT, the default behavior + // is to terminate immediately without flushing. We intercept termination + // signals here so we can break out of the event loop and run the coverage + // flush. + // + // Note: we can't use deno_signals::before_exit for this because coverage + // collection requires sending CDP messages through the V8 inspector, which + // must happen on the JS runtime thread — not the signal handler thread. + let mut maybe_termination_signal = if maybe_coverage_collector.is_some() { + deno_signals::termination_signal_stream().ok() + } else { + None + }; + // WARNING: Remember to update cli/lib/worker.rs to align with // changes made here so that they affect deno_compile as well. @@ -83,6 +100,8 @@ impl CliMainWorker { self.execute_main_module().await?; self.worker.dispatch_load_event()?; + let mut received_signal: Option = None; + loop { if let Some(hmr_runner) = maybe_hmr_runner.as_mut() { let hmr_future = hmr_runner.run().boxed_local(); @@ -108,10 +127,26 @@ impl CliMainWorker { } } else { // TODO(bartlomieju): this might not be needed anymore - self - .worker - .run_event_loop(maybe_coverage_collector.is_none()) - .await?; + if let Some(ref mut signal_stream) = maybe_termination_signal { + let event_loop_future = self + .worker + .run_event_loop(maybe_coverage_collector.is_none()) + .boxed_local(); + select! { + result = event_loop_future => { + result?; + }, + signo = signal_stream.recv() => { + received_signal = signo; + break; + } + } + } else { + self + .worker + .run_event_loop(maybe_coverage_collector.is_none()) + .await?; + } } let web_continue = self.worker.dispatch_beforeunload_event()?; @@ -123,8 +158,10 @@ impl CliMainWorker { } } - self.worker.dispatch_unload_event()?; - self.worker.dispatch_process_exit_event()?; + if received_signal.is_none() { + self.worker.dispatch_unload_event()?; + self.worker.dispatch_process_exit_event()?; + } if let Some(coverage_collector) = maybe_coverage_collector.as_mut() { coverage_collector.stop_collecting()?; @@ -133,6 +170,12 @@ impl CliMainWorker { hmr_runner.stop(); } + if let Some(signo) = received_signal { + // After flushing coverage, re-raise the signal with the default + // handler so the parent process sees the correct exit status. + deno_signals::raise_default_signal(signo); + } + Ok(self.worker.exit_code()) } diff --git a/ext/signals/lib.rs b/ext/signals/lib.rs index 9fd11e8c6db5d4..94af19fa70864c 100644 --- a/ext/signals/lib.rs +++ b/ext/signals/lib.rs @@ -183,6 +183,19 @@ impl SignalStream { } } +/// A stream that yields when any of several signals is received, +/// reporting which signal was caught. +pub struct SignalStreamWithKind { + rx: watch::Receiver, +} + +impl SignalStreamWithKind { + pub async fn recv(&mut self) -> Option { + self.rx.changed().await.ok()?; + Some(*self.rx.borrow_and_update()) + } +} + pub fn signal_stream(signo: i32) -> Result { let (tx, rx) = watch::channel(()); let cb = Box::new(move || { @@ -199,3 +212,40 @@ pub async fn ctrl_c() -> std::io::Result<()> { None => Err(std::io::Error::other("failed to receive SIGINT signal")), } } + +/// Creates an async stream that yields when a termination signal is +/// received (SIGTERM or SIGINT on unix, SIGINT on Windows). Returns +/// the signal number that was caught. +pub fn termination_signal_stream() -> std::io::Result { + let (tx, rx) = watch::channel(0i32); + let tx2 = tx.clone(); + register( + SIGINT, + true, + Box::new(move || { + tx.send_replace(SIGINT); + }), + )?; + #[cfg(unix)] + register( + SIGTERM, + true, + Box::new(move || { + tx2.send_replace(SIGTERM); + }), + )?; + #[cfg(not(unix))] + drop(tx2); + Ok(SignalStreamWithKind { rx }) +} + +/// Re-raises the given signal with the default handler so the parent +/// process sees the correct signal exit status. +pub fn raise_default_signal(signo: i32) { + // SAFETY: Restoring the default signal handler and raising the signal + // are well-defined operations on both POSIX and Windows. + unsafe { + libc::signal(signo, libc::SIG_DFL); + libc::raise(signo); + } +} diff --git a/tests/specs/coverage/sigterm_flush/__test__.jsonc b/tests/specs/coverage/sigterm_flush/__test__.jsonc new file mode 100644 index 00000000000000..6ea00def2b456f --- /dev/null +++ b/tests/specs/coverage/sigterm_flush/__test__.jsonc @@ -0,0 +1,22 @@ +{ + // Coverage data should be flushed when a process receives SIGTERM. + // This is important for tools like Playwright that use SIGTERM to + // shut down a webServer process. + // Unix-only because SIGTERM interception for coverage is only + // implemented on unix (see cli/worker.rs). + "tempDir": true, + "tests": { + "main_worker": { + "if": "unix", + "args": "run --allow-run --allow-read --allow-net --allow-env spawn_and_kill.ts server.ts", + "output": "expected.out", + "exitCode": 0 + }, + "web_worker": { + "if": "unix", + "args": "run --allow-run --allow-read --allow-net --allow-env spawn_and_kill.ts server_with_worker.ts", + "output": "expected_worker.out", + "exitCode": 0 + } + } +} diff --git a/tests/specs/coverage/sigterm_flush/expected.out b/tests/specs/coverage/sigterm_flush/expected.out new file mode 100644 index 00000000000000..c336e92f751764 --- /dev/null +++ b/tests/specs/coverage/sigterm_flush/expected.out @@ -0,0 +1,2 @@ +signal: SIGTERM +coverage files written: 1 diff --git a/tests/specs/coverage/sigterm_flush/expected_worker.out b/tests/specs/coverage/sigterm_flush/expected_worker.out new file mode 100644 index 00000000000000..8d15be46c13e76 --- /dev/null +++ b/tests/specs/coverage/sigterm_flush/expected_worker.out @@ -0,0 +1,2 @@ +signal: SIGTERM +coverage files written: 2 diff --git a/tests/specs/coverage/sigterm_flush/server.ts b/tests/specs/coverage/sigterm_flush/server.ts new file mode 100644 index 00000000000000..65e87fc2c8ebfe --- /dev/null +++ b/tests/specs/coverage/sigterm_flush/server.ts @@ -0,0 +1,4 @@ +// A simple long-running server used as the coverage target. +Deno.serve({ port: 0 }, (_req: Request) => { + return new Response("Hello"); +}); diff --git a/tests/specs/coverage/sigterm_flush/server_with_worker.ts b/tests/specs/coverage/sigterm_flush/server_with_worker.ts new file mode 100644 index 00000000000000..00bd15796f7fa6 --- /dev/null +++ b/tests/specs/coverage/sigterm_flush/server_with_worker.ts @@ -0,0 +1,16 @@ +// A long-running server that spawns a web worker on startup. +const worker = new Worker(import.meta.resolve("./worker.ts"), { + type: "module", +}); + +// Wait for worker to finish before starting the server. +await new Promise((resolve) => { + worker.onmessage = () => { + worker.terminate(); + resolve(); + }; +}); + +Deno.serve({ port: 0 }, (_req: Request) => { + return new Response("Hello"); +}); diff --git a/tests/specs/coverage/sigterm_flush/spawn_and_kill.ts b/tests/specs/coverage/sigterm_flush/spawn_and_kill.ts new file mode 100644 index 00000000000000..33b52a5daa21ce --- /dev/null +++ b/tests/specs/coverage/sigterm_flush/spawn_and_kill.ts @@ -0,0 +1,53 @@ +// Spawns a Deno server with DENO_COVERAGE_DIR set, waits for it to be +// ready, sends SIGTERM, then verifies coverage files were written. +// +// The server script to run is passed as the first argument. + +const serverScript = Deno.args[0]; +const covDir = Deno.cwd() + "/cov_output"; + +const child = new Deno.Command(Deno.execPath(), { + args: ["run", "--allow-net", "--allow-read", serverScript], + env: { + DENO_COVERAGE_DIR: covDir, + }, + stdout: "piped", + stderr: "piped", +}).spawn(); + +// Wait for the server to be ready by reading stderr for the "Listening" message. +const decoder = new TextDecoder(); +let stderr = ""; +const reader = child.stderr.getReader(); +while (true) { + const { value, done } = await reader.read(); + if (done) break; + stderr += decoder.decode(value); + if (stderr.includes("Listening")) break; +} +reader.releaseLock(); + +// Send SIGTERM to the server process. +child.kill("SIGTERM"); +const status = await child.status; + +// The process should have been killed by SIGTERM. +console.log("signal:", status.signal); + +// Check that coverage files were written. +const covFiles: string[] = []; +try { + for await (const entry of Deno.readDir(covDir)) { + if (entry.name.endsWith(".json")) { + covFiles.push(entry.name); + } + } +} catch { + // directory doesn't exist +} + +if (covFiles.length > 0) { + console.log("coverage files written:", covFiles.length); +} else { + console.log("ERROR: no coverage files found"); +} diff --git a/tests/specs/coverage/sigterm_flush/worker.ts b/tests/specs/coverage/sigterm_flush/worker.ts new file mode 100644 index 00000000000000..1fe05074b12bb8 --- /dev/null +++ b/tests/specs/coverage/sigterm_flush/worker.ts @@ -0,0 +1,8 @@ +// Worker that does some work so it shows up in coverage. +function workerTask(n: number): number { + if (n <= 1) return n; + return workerTask(n - 1) + workerTask(n - 2); +} + +const result = workerTask(10); +self.postMessage({ result });