Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions cli/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -83,6 +100,8 @@ impl CliMainWorker {
self.execute_main_module().await?;
self.worker.dispatch_load_event()?;

let mut received_signal: Option<i32> = None;

loop {
if let Some(hmr_runner) = maybe_hmr_runner.as_mut() {
let hmr_future = hmr_runner.run().boxed_local();
Expand All @@ -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()?;
Expand All @@ -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()?;
Expand All @@ -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())
}

Expand Down
50 changes: 50 additions & 0 deletions ext/signals/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i32>,
}

impl SignalStreamWithKind {
pub async fn recv(&mut self) -> Option<i32> {
self.rx.changed().await.ok()?;
Some(*self.rx.borrow_and_update())
}
}

pub fn signal_stream(signo: i32) -> Result<SignalStream, std::io::Error> {
let (tx, rx) = watch::channel(());
let cb = Box::new(move || {
Expand All @@ -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<SignalStreamWithKind> {
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);
}
}
22 changes: 22 additions & 0 deletions tests/specs/coverage/sigterm_flush/__test__.jsonc
Original file line number Diff line number Diff line change
@@ -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
}
}
}
2 changes: 2 additions & 0 deletions tests/specs/coverage/sigterm_flush/expected.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
signal: SIGTERM
coverage files written: 1
2 changes: 2 additions & 0 deletions tests/specs/coverage/sigterm_flush/expected_worker.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
signal: SIGTERM
coverage files written: 2
4 changes: 4 additions & 0 deletions tests/specs/coverage/sigterm_flush/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// A simple long-running server used as the coverage target.
Deno.serve({ port: 0 }, (_req: Request) => {
return new Response("Hello");
});
16 changes: 16 additions & 0 deletions tests/specs/coverage/sigterm_flush/server_with_worker.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
worker.onmessage = () => {
worker.terminate();
resolve();
};
});

Deno.serve({ port: 0 }, (_req: Request) => {
return new Response("Hello");
});
53 changes: 53 additions & 0 deletions tests/specs/coverage/sigterm_flush/spawn_and_kill.ts
Original file line number Diff line number Diff line change
@@ -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");
}
8 changes: 8 additions & 0 deletions tests/specs/coverage/sigterm_flush/worker.ts
Original file line number Diff line number Diff line change
@@ -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 });
Loading