From 5a139dcd57e9f1fbf229f6a0ff10a1f4659ad1c5 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Wed, 30 Sep 2026 06:01:35 +1300 Subject: [PATCH 1/4] Use `send_ctrlc` to terminate child app process. --- Cargo.lock | 12 ++++++++++++ packages/cli/Cargo.toml | 1 + packages/cli/src/build/builder.rs | 32 +++++++++++-------------------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1dffb9b6a..feff6025e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3315,6 +3315,7 @@ dependencies = [ "schemars", "self-replace", "self_update", + "send_ctrlc", "sentry-backtrace", "serde", "serde_json", @@ -11536,6 +11537,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_ctrlc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07beb664b54f51140baf2769d12d5eb07d0e3eccee78fb95c3e76c2644a4cad" +dependencies = [ + "libc", + "tokio", + "windows-sys 0.61.2", +] + [[package]] name = "send_wrapper" version = "0.6.0" diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 06847065e5..f4e28b1b1c 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -54,6 +54,7 @@ krates = { workspace = true } regex = "1.12.3" console = "0.16.0" ctrlc = "3.4.7" +send_ctrlc = { version = "0.6.0", features = ["tokio"] } axum = { workspace = true, default-features = true, features = ["ws"] } axum-server = { workspace = true, features = ["tls-rustls-no-provider"] } diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index 2e4fc9be5d..0ddb49ab88 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -6,6 +6,7 @@ use crate::{BuildPhaseProfile, opt::process_file_to}; use anyhow::{Context, Error, bail}; use futures_util::{FutureExt, future::OptionFuture, pin_mut}; use itertools::Itertools; +use send_ctrlc::{Interruptible, InterruptibleCommand, tokio::InterruptibleChild}; use std::{ collections::HashSet, env, @@ -20,7 +21,7 @@ use subsecond_types::JumpTable; use target_lexicon::Architecture; use tokio::{ io::{AsyncBufReadExt, BufReader, Lines}, - process::{Child, ChildStderr, ChildStdout, Command}, + process::{ChildStderr, ChildStdout, Command}, task::JoinHandle, }; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -72,7 +73,7 @@ pub(crate) struct AppBuilder { pub runtime_asset_dir: Option, // These might be None if the app died or the user did not specify a server - pub child: Option, + pub child: Option, // stdio for the app so we can read its stdout/stderr // we don't map stdin today (todo) but most apps don't need it @@ -708,7 +709,8 @@ impl AppBuilder { /// Gracefully kill the process and all of its children /// - /// Uses the `SIGTERM` signal on unix and `taskkill` on windows. + /// Uses `send_ctrlc` to send `SIGTERM` on unix and `CTRL_BREAK_EVENT` on windows, to cleanly + /// shut down the child process. /// This complex logic is necessary for things like window state preservation to work properly. /// /// Also wipes away the entropy executables if they exist. @@ -720,26 +722,14 @@ impl AppBuilder { return; }; - let Some(pid) = process.id() else { + if process.id().is_none() { _ = process.kill().await; return; - }; - - // on unix, we can send a signal to the process to shut down - #[cfg(unix)] - { - _ = Command::new("kill") - .args(["-s", "TERM", &pid.to_string()]) - .spawn(); } - // on windows, use the `taskkill` command - #[cfg(windows)] - { - _ = Command::new("taskkill") - .args(["/PID", &pid.to_string()]) - .spawn(); - } + // Ask the child to shut down gracefully; `kill_on_drop(true)` at spawn time is the + // forceful fallback if it doesn't exit within the timeout below. + _ = process.terminate(); // join the wait with a 100ms timeout futures_util::select! { @@ -1008,7 +998,7 @@ impl AppBuilder { .stderr(Stdio::piped()) .stdout(Stdio::piped()) .kill_on_drop(true) - .spawn()?; + .spawn_interruptible()?; let stdout = BufReader::new(child.stdout.take().unwrap()); let stderr = BufReader::new(child.stderr.take().unwrap()); @@ -1070,7 +1060,7 @@ impl AppBuilder { .stderr(Stdio::piped()) .stdout(Stdio::piped()) .kill_on_drop(true) - .spawn()?; + .spawn_interruptible()?; let stdout = BufReader::new(child.stdout.take().unwrap()); let stderr = BufReader::new(child.stderr.take().unwrap()); From 03ef9348aa255f560f0374d40821e0590d95834d Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Wed, 30 Sep 2026 06:58:22 +1300 Subject: [PATCH 2/4] Suppress warning when shutdown message fails to be delivered. --- packages/cli/src/serve/server.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/serve/server.rs b/packages/cli/src/serve/server.rs index 20c57ddb4d..bf204df515 100644 --- a/packages/cli/src/serve/server.rs +++ b/packages/cli/src/serve/server.rs @@ -374,11 +374,23 @@ impl WebServer { .send(Message::Text(serde_json::to_string(&msg).unwrap().into())) .await { - tracing::warn!( - "Failed to send devserver message to client (build_id: {:?}, pid: {:?}): {err}", - socket.build_id, - socket.pid - ); + // By the time a [`DevserverMsg::Shutdown`] is sent, the CLI has already + // killed the spawned app processes (see `AppServer::shutdown`), so their + // devserver sockets are expected to already be gone. A failure to deliver that + // message is expected, so it's logged at `debug` instead of `warn`. + if matches!(msg, DevserverMsg::Shutdown) { + tracing::debug!( + "Failed to send devserver message to client (build_id: {:?}, pid: {:?}): {err}", + socket.build_id, + socket.pid + ); + } else { + tracing::warn!( + "Failed to send devserver message to client (build_id: {:?}, pid: {:?}): {err}", + socket.build_id, + socket.pid + ); + } } } } From c980db2bfb7e2e8b6adfb543e7c81449b62571f5 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Thu, 1 Oct 2026 06:54:34 +1300 Subject: [PATCH 3/4] Gracefully shut down desktop apps on Windows. `send_ctrlc` sends `CTRL_BREAK_EVENT` on Windows, which Rust handles with `ExitProcess`, so desktop apps no longer received `WM_CLOSE` and skipped saving their window state. It also does not reach apps without a console. * CLI: also run `taskkill /PID` (no `/F`) on Windows to send `WM_CLOSE`, hiding its expected "success" / "not found" output. * Desktop: handle Ctrl-C / Ctrl-Break on Windows via `ctrlc` and trigger `UserWindowEvent::Shutdown`. * Fix stale timeout comment in `soft_kill`. --- Cargo.lock | 1 + packages/cli/src/build/builder.rs | 42 ++++++++++++++++++++++++++++--- packages/desktop/Cargo.toml | 3 +++ packages/desktop/src/app.rs | 13 +++++++++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index feff6025e9..a649deaf6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3477,6 +3477,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", + "ctrlc", "dioxus", "dioxus-asset-resolver", "dioxus-cli-config", diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index 0ddb49ab88..5a5f43c1de 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -722,16 +722,52 @@ impl AppBuilder { return; }; - if process.id().is_none() { + let Some(pid) = process.id() else { _ = process.kill().await; return; - } + }; // Ask the child to shut down gracefully; `kill_on_drop(true)` at spawn time is the // forceful fallback if it doesn't exit within the timeout below. _ = process.terminate(); - // join the wait with a 100ms timeout + // `CTRL_BREAK_EVENT` only reaches processes attached to a console, and desktop apps + // save their window state on `WM_CLOSE`. + // + // We call `taskkill` without `/F` so that it sends `WM_CLOSE`. + #[cfg(windows)] + tokio::spawn(async move { + let Ok(output) = Command::new("taskkill") + .args(["/PID", &pid.to_string()]) + .stdin(Stdio::null()) + .output() + .await + else { + return; + }; + + // When terminating a child process during development, `taskkill` outputs + // "SUCCESS: Sent termination signal ..." when it sends `WM_CLOSE`, + // and "ERROR: The process ... not found." when the child has already exited from + // `CTRL_BREAK_EVENT`. + // + // These are expected output and dirty the console, so we filter them out of the logs. + let log_taskkill_output = |bytes: &[u8], is_expected: fn(&str) -> bool| { + String::from_utf8_lossy(bytes) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !is_expected(line)) + .for_each(|line| tracing::warn!("taskkill: {line}")); + }; + log_taskkill_output(&output.stdout, |line| { + line.starts_with("SUCCESS: Sent termination signal") + || (line.starts_with("ERROR: The process") && line.ends_with("not found.")) + }); + }); + #[cfg(not(windows))] + let _ = pid; + + // join the wait with a 1 second timeout futures_util::select! { _ = process.wait().fuse() => {} _ = tokio::time::sleep(std::time::Duration::from_millis(1000)).fuse() => {} diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 729c0f91ef..dd03cff8a1 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -59,6 +59,9 @@ webbrowser = { workspace = true } [target.'cfg(unix)'.dependencies] signal-hook = "0.3.18" +[target.'cfg(windows)'.dependencies] +ctrlc = { version = "3.4", features = ["termination"] } + [target.'cfg(target_os = "linux")'.dependencies] wry = { workspace = true, features = ["os-webview", "protocol", "linux-body"] } diff --git a/packages/desktop/src/app.rs b/packages/desktop/src/app.rs index c51cceee2a..13c37e7bd1 100644 --- a/packages/desktop/src/app.rs +++ b/packages/desktop/src/app.rs @@ -653,7 +653,18 @@ impl App { /// Whenever sigkill is sent, we shut down the app and save the window state #[cfg(debug_assertions)] fn connect_preserve_window_state_handler(&self) { - // TODO: make this work on windows + #[cfg(windows)] + { + // `ctrlc` handles `CTRL_C_EVENT` and `CTRL_BREAK_EVENT`, which the CLI sends on shutdown. + // This fails if the user already installed a handler, in which case we leave theirs. + let target = self.app_context.proxy.clone(); + _ = ctrlc::set_handler(move || { + if target.send_event(UserWindowEvent::Shutdown).is_err() { + std::process::exit(0); + } + }); + } + #[cfg(unix)] { // Wire up the trap From e6a16a1ee5b60445bfbba804de79628e636683a0 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Thu, 1 Oct 2026 07:17:49 +1300 Subject: [PATCH 4/4] Update `ctrlc` version in `desktop` to `3.4.7`. --- packages/desktop/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index dd03cff8a1..fe267ae01c 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -60,7 +60,7 @@ webbrowser = { workspace = true } signal-hook = "0.3.18" [target.'cfg(windows)'.dependencies] -ctrlc = { version = "3.4", features = ["termination"] } +ctrlc = { version = "3.4.7", features = ["termination"] } [target.'cfg(target_os = "linux")'.dependencies] wry = { workspace = true, features = ["os-webview", "protocol", "linux-body"] }