diff --git a/.changes/use-plugin-cleanups-and-kill-tree.md b/.changes/use-plugin-cleanups-and-kill-tree.md new file mode 100644 index 000000000000..61b9713b3d39 --- /dev/null +++ b/.changes/use-plugin-cleanups-and-kill-tree.md @@ -0,0 +1,14 @@ +--- +'@tauri-apps/tauri': 'minor:enhance' +--- + +Introduce plugin-level cleanup hooks and centralize process-tree termination logic. + +This change: +- Adds `kill_process_tree` helper to the runtime for cross-platform process-tree shutdown. +- Adds a new `cleanup_before_exit` lifecycle hook to plugins and wires it so plugin authors + can handle sidecar shutdown without core runtime logic. +- Removes hardcoded sidecar-draining from the runtime and delegates shutdown behavior to plugins. + +This allows plugins (such as the shell plugin) to manage their own sidecar processes cleanly +and improves extensibility of the Tauri runtime. Fixes #14360. diff --git a/crates/tauri-cli/src/interface/rust/desktop.rs b/crates/tauri-cli/src/interface/rust/desktop.rs index f5edb4d6e693..92b87de34146 100644 --- a/crates/tauri-cli/src/interface/rust/desktop.rs +++ b/crates/tauri-cli/src/interface/rust/desktop.rs @@ -28,6 +28,41 @@ pub struct DevChild { impl DevProcess for DevChild { fn kill(&self) -> std::io::Result<()> { + if let Some(pid) = self.dev_child.id() { + #[cfg(windows)] + { + use std::process::Command; + let ps = format!( + "function Kill-Tree {{ Param([int]$ppid); Get-CimInstance Win32_Process | Where-Object {{ $_.ParentProcessId -eq $ppid }} | ForEach-Object {{ Kill-Tree $_.ProcessId }}; Stop-Process -Id $ppid -ErrorAction SilentlyContinue }}; Kill-Tree {}", + pid + ); + let _ = Command::new("powershell") + .arg("-NoProfile") + .arg("-Command") + .arg(ps) + .status(); + } + + #[cfg(not(windows))] + { + use std::process::Command; + let sh = format!(r#" +getcpid() {{ + for cpid in $(pgrep -P "$1" 2>/dev/null || true); do + getcpid "$cpid" + echo "$cpid" + done +}} +for p in $(getcpid {pid}); do + kill -9 "$p" 2>/dev/null || true +done +kill -9 {pid} 2>/dev/null || true +"#, pid = pid); + + let _ = Command::new("sh").arg("-c").arg(sh).status(); + } + } + self.dev_child.kill()?; self.manually_killed_app.store(true, Ordering::Relaxed); Ok(()) diff --git a/crates/tauri/src/app.rs b/crates/tauri/src/app.rs index b950895bc002..97f8a323363e 100644 --- a/crates/tauri/src/app.rs +++ b/crates/tauri/src/app.rs @@ -501,6 +501,8 @@ impl AppHandle { Ok(()) } + + /// Removes the plugin with the given name. /// /// # Examples @@ -1037,6 +1039,13 @@ macro_rules! shared_app_impl { for (_, webview) in self.manager.webviews() { webview.resources_table().clear(); } + // run plugin cleanup hooks so plugins can perform shutdown tasks (e.g. stop sidecars) + self + .manager + .plugins + .lock() + .unwrap() + .cleanup_before_exit(self.app_handle()); } /// Gets the invoke key that must be referenced when using [`crate::webview::InvokeRequest`]. diff --git a/crates/tauri/src/plugin.rs b/crates/tauri/src/plugin.rs index 9c7f9a1abaaf..114f700f78a3 100644 --- a/crates/tauri/src/plugin.rs +++ b/crates/tauri/src/plugin.rs @@ -104,6 +104,15 @@ pub trait Plugin: Send { #[allow(unused_variables)] fn on_event(&mut self, app: &AppHandle, event: &RunEvent) {} + /// Callback invoked when the application is performing cleanup before exit. + /// + /// Plugins can use this hook to perform any process shutdown/cleanup they need + /// to do before the runtime exits (for example, killing sidecars or stopping + /// background tasks). This hook is executed inside `App::cleanup_before_exit` during application shutdown. + + #[allow(unused_variables)] + fn cleanup_before_exit(&mut self, app: &AppHandle) {} + /// Extend commands to [`crate::Builder::invoke_handler`]. #[allow(unused_variables)] fn extend_api(&mut self, invoke: Invoke) -> bool { @@ -979,6 +988,15 @@ impl PluginStore { .for_each(|plugin| plugin.on_event(app, event)) } + /// Runs the cleanup_before_exit hook for all plugins in the store. + pub(crate) fn cleanup_before_exit(&mut self, app: &AppHandle) { + self.store.iter_mut().for_each(|plugin| { + #[cfg(feature = "tracing")] + let _span = tracing::trace_span!("plugin::hooks::cleanup_before_exit", name = plugin.name()).entered(); + plugin.cleanup_before_exit(app) + }) + } + /// Runs the plugin `extend_api` hook if it exists. Returns whether the invoke message was handled or not. /// /// The message is not handled when the plugin exists **and** the command does not. diff --git a/crates/tauri/src/process.rs b/crates/tauri/src/process.rs index 76937b1fe4b6..ed357caa9f26 100644 --- a/crates/tauri/src/process.rs +++ b/crates/tauri/src/process.rs @@ -128,3 +128,98 @@ fn restart_macos_app(current_binary: &std::path::Path, env: &Env) { } } } + +/// Kill a process and its descendant process tree (best-effort). +/// +/// On Windows this function prefers the built-in `taskkill /T /PID /F` +/// utility which can terminate a process tree. If `taskkill` is unavailable +/// or returns a non-zero exit status (for example due to permissions), the +/// function falls back to a PowerShell-based recursive traversal which mirrors +/// the previous implementation. +/// +/// On Unix-like systems a small shell function using `pgrep -P` is used to +/// collect child PIDs and send `SIGKILL` to descendants and the root PID. +/// +/// Note: terminating processes is inherently best-effort and may fail for +/// protected or system processes, or when the caller lacks sufficient +/// privileges. Callers should handle and log any errors returned by this +/// function. + +// TODO: Move this helper into the `process` plugin in the plugins-workspace repo. +pub fn kill_process_tree(pid: u32) -> std::io::Result<()> { + #[cfg(windows)] + { + use std::process::Command; + + // Prefer the built-in `taskkill` utility on Windows which can terminate a process + // tree with `/T`. If that fails (permissions, not found, or non-zero exit), fall + // back to a PowerShell-based recursive stop that mirrors the previous behavior. + let pid_s = pid.to_string(); + + if let Ok(status) = Command::new("taskkill") + .args(&["/T", "/PID", &pid_s, "/F"]) // /F to force termination + .status() + { + if status.success() { + return Ok(()); + } + // If taskkill returned non-zero, fall through to try PowerShell. + } + + // Fallback: Use PowerShell to recursively find and stop child processes, then stop the root. + // This mirrors the approach used elsewhere in the project (tauri-cli) and preserves + // behavior on systems where taskkill isn't available or failed due to permissions. + let ps = format!( + "function Kill-Tree {{ Param([int]$ppid); Get-CimInstance Win32_Process | Where-Object {{ $_.ParentProcessId -eq $ppid }} | ForEach-Object {{ Kill-Tree $_.ProcessId }}; Stop-Process -Id $ppid -ErrorAction SilentlyContinue }}; Kill-Tree {}", + pid + ); + + let status = Command::new("powershell") + .arg("-NoProfile") + .arg("-Command") + .arg(ps) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("kill-tree failed: powershell exited with status: {}", status), + )) + } + } + + #[cfg(not(windows))] + { + use std::process::Command; + + // On Unix, recursively collect children via pgrep -P and kill them. We use a small + // shell function to traverse descendants and then kill them. Use SIGKILL to ensure + // termination (best effort). + let sh = format!(r#" +getcpid() {{ + for cpid in $(pgrep -P "$1" 2>/dev/null || true); do + getcpid "$cpid" + echo "$cpid" + done +}} +for p in $(getcpid {pid}); do + kill -9 "$p" 2>/dev/null || true +done +kill -9 {pid} 2>/dev/null || true +"#, pid = pid); + + let status = Command::new("sh").arg("-c").arg(sh).status()?; + + if status.success() { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("sh kill-tree failed with status: {}", status), + )) + } + } +} +