From 047a684e320d50240259809e7bf53c079671277b Mon Sep 17 00:00:00 2001 From: Sakina Farukh Ahemad Date: Mon, 10 Nov 2025 17:50:08 +0530 Subject: [PATCH 1/5] Add kill-tree helper and runtime sidecar PID registry --- .../tauri-cli/src/interface/rust/desktop.rs | 37 +++++++++ crates/tauri/CHANGELOG.md | 10 +++ crates/tauri/PR_DRAFT.md | 35 +++++++++ crates/tauri/src/app.rs | 21 +++++ crates/tauri/src/manager/mod.rs | 23 +++++- crates/tauri/src/process.rs | 76 +++++++++++++++++++ 6 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 crates/tauri/PR_DRAFT.md diff --git a/crates/tauri-cli/src/interface/rust/desktop.rs b/crates/tauri-cli/src/interface/rust/desktop.rs index f5edb4d6e693..b0f8a11163f1 100644 --- a/crates/tauri-cli/src/interface/rust/desktop.rs +++ b/crates/tauri-cli/src/interface/rust/desktop.rs @@ -28,6 +28,43 @@ pub struct DevChild { impl DevProcess for DevChild { fn kill(&self) -> std::io::Result<()> { + // Best-effort: attempt to kill the child process tree before killing the process + // This mirrors the approach used in the main `tauri` crate's helper. + 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/CHANGELOG.md b/crates/tauri/CHANGELOG.md index 5dfa010859da..154702c0639a 100644 --- a/crates/tauri/CHANGELOG.md +++ b/crates/tauri/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## \[Unreleased] + +### Enhancements + +- Added a best-effort process tree killer and sidecar PID registry: + - `kill_process_tree(pid: u32)` helper (cross-platform, shell/PowerShell based). + - Runtime-side sidecar PID registry and `AppHandle::register_sidecar` / `unregister_sidecar`. + - CLI dev-run integration attempts to kill sidecar descendant processes during terminate. + This helps ensure descendant processes spawned by sidecars are terminated when a sidecar is killed. (Fixes #14360) + ## \[2.9.2] ### Bug Fixes diff --git a/crates/tauri/PR_DRAFT.md b/crates/tauri/PR_DRAFT.md new file mode 100644 index 000000000000..8309b75b5837 --- /dev/null +++ b/crates/tauri/PR_DRAFT.md @@ -0,0 +1,35 @@ +Title: Add kill-tree helper and runtime sidecar PID registry (Fixes #14360) + +Summary + +This PR adds a best-effort process-tree killer and a lightweight runtime registry to help ensure that descendant processes spawned by sidecars are terminated when a sidecar is killed. + +What I changed + +- crates/tauri/src/process.rs + - Added `pub fn kill_process_tree(pid: u32) -> std::io::Result<()>` which invokes platform-specific shell/PowerShell snippets to terminate a process tree (best-effort, no new deps). + +- crates/tauri/src/manager/mod.rs + - Added `sidecar_pids: Arc>>` and methods: `register_sidecar`, `unregister_sidecar`, `drain_sidecar_pids`. + +- crates/tauri/src/app.rs + - Added `AppHandle::register_sidecar` and `AppHandle::unregister_sidecar` convenience methods. + - Wired `cleanup_before_exit()` to drain the sidecar registry and call `kill_process_tree` for each registered PID. + +- crates/tauri-cli/src/interface/rust/desktop.rs + - Dev-run kill path now attempts a best-effort kill-tree invocation for dev child processes. + +Testing done + +- Ran `cargo test -p tauri` locally: unit tests and doc-tests passed. +- The changes are designed to be best-effort (no panics on failures). The runtime requires spawners to call `register_sidecar(pid)` after spawning a sidecar so it can be cleaned up at exit. + +Notes & follow-ups + +- This is a pragmatic, short-term fix using shell/PowerShell helpers. We can consider a Rust-native implementation later for better portability and finer control. +- We should update any sidecar spawners (plugins or examples that call `Command::spawn()`) to call `app.handle().register_sidecar(child.id() as u32)` after spawning and `unregister_sidecar` when stopping the sidecar. +- Add an integration test that spawns a parent process which itself spawns a child, registers the parent PID, and asserts both are gone after cleanup. + +References + +- Fixes #14360 diff --git a/crates/tauri/src/app.rs b/crates/tauri/src/app.rs index b950895bc002..3758f4c364a5 100644 --- a/crates/tauri/src/app.rs +++ b/crates/tauri/src/app.rs @@ -501,6 +501,19 @@ impl AppHandle { Ok(()) } + /// Register a sidecar PID to be killed on application cleanup. + /// + /// This is a best-effort API: failures to kill child processes are logged but do not + /// cause the application to panic. + pub fn register_sidecar(&self, pid: u32) { + self.manager.register_sidecar(pid); + } + + /// Unregister a previously-registered sidecar PID. + pub fn unregister_sidecar(&self, pid: u32) { + self.manager.unregister_sidecar(pid); + } + /// Removes the plugin with the given name. /// /// # Examples @@ -1037,6 +1050,14 @@ macro_rules! shared_app_impl { for (_, webview) in self.manager.webviews() { webview.resources_table().clear(); } + // Best-effort: kill registered sidecar process trees. + // We drain the registry so we don't attempt to kill them again. + let sidecar_pids = self.manager.drain_sidecar_pids(); + for pid in sidecar_pids { + if let Err(e) = crate::process::kill_process_tree(pid) { + log::warn!("failed to kill sidecar pid {}: {}", pid, e); + } + } } /// Gets the invoke key that must be referenced when using [`crate::webview::InvokeRequest`]. diff --git a/crates/tauri/src/manager/mod.rs b/crates/tauri/src/manager/mod.rs index 2126dd8957ce..6c8de78bd2ed 100644 --- a/crates/tauri/src/manager/mod.rs +++ b/crates/tauri/src/manager/mod.rs @@ -4,7 +4,7 @@ use std::{ borrow::Cow, - collections::HashMap, + collections::{HashMap, HashSet}, fmt, sync::{atomic::AtomicBool, Arc, Mutex, MutexGuard}, }; @@ -214,6 +214,8 @@ pub struct AppManager { /// Application Resources Table pub(crate) resources_table: Arc>, + /// Registered sidecar PIDs that should be cleaned up on app exit. + pub(crate) sidecar_pids: Arc>>, /// Runtime-generated invoke key. pub(crate) invoke_key: String, @@ -322,6 +324,7 @@ impl AppManager { pattern: Arc::new(context.pattern), plugin_global_api_scripts: Arc::new(context.plugin_global_api_scripts), resources_table: Arc::default(), + sidecar_pids: Arc::default(), invoke_key, channel_interceptor, restart_on_exit: AtomicBool::new(false), @@ -696,6 +699,24 @@ impl AppManager { pub(crate) fn invoke_key(&self) -> &str { &self.invoke_key } + + /// Register a sidecar PID to be cleaned up on application exit. + pub fn register_sidecar(&self, pid: u32) { + let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); + pids.insert(pid); + } + + /// Unregister a previously-registered sidecar PID. + pub fn unregister_sidecar(&self, pid: u32) { + let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); + pids.remove(&pid); + } + + /// Drain and return the currently registered sidecar PIDs. + pub fn drain_sidecar_pids(&self) -> Vec { + let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); + pids.drain().collect() + } } #[cfg(desktop)] diff --git a/crates/tauri/src/process.rs b/crates/tauri/src/process.rs index 76937b1fe4b6..758687b014da 100644 --- a/crates/tauri/src/process.rs +++ b/crates/tauri/src/process.rs @@ -128,3 +128,79 @@ fn restart_macos_app(current_binary: &std::path::Path, env: &Env) { } } } + +/// Kill a process and all of its descendant processes (process tree). +/// +/// This helper will attempt a platform-appropriate recursive kill. It does not add any +/// extra crate dependencies and instead delegates to the system shell utilities. +/// +/// - On Windows it calls PowerShell and uses `Get-CimInstance Win32_Process` to traverse +/// the process tree and `Stop-Process` to terminate processes. +/// - On Unix (Linux / macOS / *nix) it uses `pgrep -P` recursively to find children and +/// sends SIGKILL to them. It tolerates missing `pgrep` by returning an error from the +/// spawned shell command. +/// +/// Note: This function attempts a best-effort termination and will return the +/// underlying I/O error if the platform command failed to spawn or returned a non-zero +/// exit status. +pub fn kill_process_tree(pid: u32) -> std::io::Result<()> { + #[cfg(windows)] + { + use std::process::Command; + + // Use PowerShell to recursively find and stop child processes, then stop the root. + // This mirrors the approach used elsewhere in the project (tauri-cli). + 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!("powershell kill-tree failed 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), + )) + } + } +} + From 6cef7c47877e91815cce53683c273de42d9f4e7d Mon Sep 17 00:00:00 2001 From: Sakina Farukh Ahemad Date: Mon, 10 Nov 2025 18:18:31 +0530 Subject: [PATCH 2/5] fixes --- .../tauri-cli/src/interface/rust/desktop.rs | 2 -- crates/tauri/src/app.rs | 9 +-------- crates/tauri/src/manager/mod.rs | 6 ------ crates/tauri/src/process.rs | 19 ------------------- 4 files changed, 1 insertion(+), 35 deletions(-) diff --git a/crates/tauri-cli/src/interface/rust/desktop.rs b/crates/tauri-cli/src/interface/rust/desktop.rs index b0f8a11163f1..92b87de34146 100644 --- a/crates/tauri-cli/src/interface/rust/desktop.rs +++ b/crates/tauri-cli/src/interface/rust/desktop.rs @@ -28,8 +28,6 @@ pub struct DevChild { impl DevProcess for DevChild { fn kill(&self) -> std::io::Result<()> { - // Best-effort: attempt to kill the child process tree before killing the process - // This mirrors the approach used in the main `tauri` crate's helper. if let Some(pid) = self.dev_child.id() { #[cfg(windows)] { diff --git a/crates/tauri/src/app.rs b/crates/tauri/src/app.rs index 3758f4c364a5..a8d699babcbd 100644 --- a/crates/tauri/src/app.rs +++ b/crates/tauri/src/app.rs @@ -501,15 +501,10 @@ impl AppHandle { Ok(()) } - /// Register a sidecar PID to be killed on application cleanup. - /// - /// This is a best-effort API: failures to kill child processes are logged but do not - /// cause the application to panic. pub fn register_sidecar(&self, pid: u32) { self.manager.register_sidecar(pid); } - /// Unregister a previously-registered sidecar PID. pub fn unregister_sidecar(&self, pid: u32) { self.manager.unregister_sidecar(pid); } @@ -1050,9 +1045,7 @@ macro_rules! shared_app_impl { for (_, webview) in self.manager.webviews() { webview.resources_table().clear(); } - // Best-effort: kill registered sidecar process trees. - // We drain the registry so we don't attempt to kill them again. - let sidecar_pids = self.manager.drain_sidecar_pids(); + let sidecar_pids = self.manager.drain_sidecar_pids(); for pid in sidecar_pids { if let Err(e) = crate::process::kill_process_tree(pid) { log::warn!("failed to kill sidecar pid {}: {}", pid, e); diff --git a/crates/tauri/src/manager/mod.rs b/crates/tauri/src/manager/mod.rs index 6c8de78bd2ed..05f9b0b9846e 100644 --- a/crates/tauri/src/manager/mod.rs +++ b/crates/tauri/src/manager/mod.rs @@ -214,7 +214,6 @@ pub struct AppManager { /// Application Resources Table pub(crate) resources_table: Arc>, - /// Registered sidecar PIDs that should be cleaned up on app exit. pub(crate) sidecar_pids: Arc>>, /// Runtime-generated invoke key. @@ -700,19 +699,14 @@ impl AppManager { &self.invoke_key } - /// Register a sidecar PID to be cleaned up on application exit. pub fn register_sidecar(&self, pid: u32) { let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); pids.insert(pid); } - - /// Unregister a previously-registered sidecar PID. pub fn unregister_sidecar(&self, pid: u32) { let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); pids.remove(&pid); } - - /// Drain and return the currently registered sidecar PIDs. pub fn drain_sidecar_pids(&self) -> Vec { let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); pids.drain().collect() diff --git a/crates/tauri/src/process.rs b/crates/tauri/src/process.rs index 758687b014da..227fe7ab8ec0 100644 --- a/crates/tauri/src/process.rs +++ b/crates/tauri/src/process.rs @@ -129,27 +129,11 @@ fn restart_macos_app(current_binary: &std::path::Path, env: &Env) { } } -/// Kill a process and all of its descendant processes (process tree). -/// -/// This helper will attempt a platform-appropriate recursive kill. It does not add any -/// extra crate dependencies and instead delegates to the system shell utilities. -/// -/// - On Windows it calls PowerShell and uses `Get-CimInstance Win32_Process` to traverse -/// the process tree and `Stop-Process` to terminate processes. -/// - On Unix (Linux / macOS / *nix) it uses `pgrep -P` recursively to find children and -/// sends SIGKILL to them. It tolerates missing `pgrep` by returning an error from the -/// spawned shell command. -/// -/// Note: This function attempts a best-effort termination and will return the -/// underlying I/O error if the platform command failed to spawn or returned a non-zero -/// exit status. pub fn kill_process_tree(pid: u32) -> std::io::Result<()> { #[cfg(windows)] { use std::process::Command; - // Use PowerShell to recursively find and stop child processes, then stop the root. - // This mirrors the approach used elsewhere in the project (tauri-cli). 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 @@ -175,9 +159,6 @@ pub fn kill_process_tree(pid: u32) -> std::io::Result<()> { { 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 From 130dad20b972bca9c02d7d8842b485e8493a56bd Mon Sep 17 00:00:00 2001 From: Sakina Farukh Ahemad Date: Wed, 12 Nov 2025 13:10:07 +0530 Subject: [PATCH 3/5] move sidecar cleanup to generic cleanup_before_exit hook --- crates/tauri/CHANGELOG.md | 7 ++++--- crates/tauri/PR_DRAFT.md | 37 +++++++++++++++++++++------------ crates/tauri/src/app.rs | 19 +++++++---------- crates/tauri/src/manager/mod.rs | 17 +-------------- crates/tauri/src/plugin.rs | 18 ++++++++++++++++ crates/tauri/src/process.rs | 5 +++++ 6 files changed, 59 insertions(+), 44 deletions(-) diff --git a/crates/tauri/CHANGELOG.md b/crates/tauri/CHANGELOG.md index 154702c0639a..481c1d5c1e97 100644 --- a/crates/tauri/CHANGELOG.md +++ b/crates/tauri/CHANGELOG.md @@ -4,11 +4,12 @@ ### Enhancements -- Added a best-effort process tree killer and sidecar PID registry: +- Added a best-effort process tree killer and plugin-level cleanup hook: - `kill_process_tree(pid: u32)` helper (cross-platform, shell/PowerShell based). - - Runtime-side sidecar PID registry and `AppHandle::register_sidecar` / `unregister_sidecar`. + - Plugin lifecycle hook `Plugin::cleanup_before_exit` and `PluginStore::cleanup_before_exit` — the runtime now delegates shutdown cleanup to plugins (for example the shell plugin) so they can manage sidecar lifecycle and process termination. + - Removed public convenience `AppHandle::register_sidecar` / `unregister_sidecar`: sidecar lifecycle and registration should be owned by the plugin responsible for spawning them. - CLI dev-run integration attempts to kill sidecar descendant processes during terminate. - This helps ensure descendant processes spawned by sidecars are terminated when a sidecar is killed. (Fixes #14360) + This ensures descendant processes spawned by sidecars are terminated while keeping process-control logic inside the plugin that owns the sidecar (Fixes #14360) ## \[2.9.2] diff --git a/crates/tauri/PR_DRAFT.md b/crates/tauri/PR_DRAFT.md index 8309b75b5837..0c59d8466566 100644 --- a/crates/tauri/PR_DRAFT.md +++ b/crates/tauri/PR_DRAFT.md @@ -1,34 +1,45 @@ -Title: Add kill-tree helper and runtime sidecar PID registry (Fixes #14360) +Title: Use plugin cleanup hooks for sidecar shutdown; add kill-tree helper (Fixes #14360) Summary -This PR adds a best-effort process-tree killer and a lightweight runtime registry to help ensure that descendant processes spawned by sidecars are terminated when a sidecar is killed. +This PR centralizes process-kill helpers in the runtime and moves responsibility for sidecar shutdown to plugins by adding a plugin-level cleanup hook. The core runtime no longer contains hardcoded sidecar draining logic; instead it calls `Plugin::cleanup_before_exit` so plugins (for example, the shell plugin) can take care of stopping sidecars and terminating process trees. What I changed - crates/tauri/src/process.rs - Added `pub fn kill_process_tree(pid: u32) -> std::io::Result<()>` which invokes platform-specific shell/PowerShell snippets to terminate a process tree (best-effort, no new deps). -- crates/tauri/src/manager/mod.rs - - Added `sidecar_pids: Arc>>` and methods: `register_sidecar`, `unregister_sidecar`, `drain_sidecar_pids`. +- crates/tauri/src/plugin.rs + - Added a plugin lifecycle hook `fn cleanup_before_exit(&mut self, app: &AppHandle) {}` with a default no-op. + - Added `PluginStore::cleanup_before_exit(&mut self, app: &AppHandle)` which invokes the hook for every registered plugin. - crates/tauri/src/app.rs - - Added `AppHandle::register_sidecar` and `AppHandle::unregister_sidecar` convenience methods. - - Wired `cleanup_before_exit()` to drain the sidecar registry and call `kill_process_tree` for each registered PID. + - Replaced the hardcoded sidecar PID draining and kill logic in the app shutdown path with a call to `plugins.lock().unwrap().cleanup_before_exit(self.app_handle())` so plugins perform shutdown work. + - Removed the previous `AppHandle::register_sidecar` / `unregister_sidecar` convenience methods from the public API (spawners should migrate to plugin-managed registries). -- crates/tauri-cli/src/interface/rust/desktop.rs - - Dev-run kill path now attempts a best-effort kill-tree invocation for dev child processes. +- Note on migration / shell plugin responsibilities + - The shell plugin (which lives in the plugins workspace) should implement `cleanup_before_exit` and perform any sidecar shutdown it needs. A minimal implementation is to drain the manager's sidecar registry and call `kill_process_tree` for each PID, e.g.: + + ```text + fn cleanup_before_exit(&mut self, app: &AppHandle) { + let pids = app.manager.drain_sidecar_pids(); + for pid in pids { + let _ = tauri::process::kill_process_tree(pid); + } + } + ``` + + This keeps process-management details inside the shell plugin (owner of sidecar lifecycle) and makes the runtime more extensible. Testing done -- Ran `cargo test -p tauri` locally: unit tests and doc-tests passed. -- The changes are designed to be best-effort (no panics on failures). The runtime requires spawners to call `register_sidecar(pid)` after spawning a sidecar so it can be cleaned up at exit. +- Ran `cargo check -p tauri` locally to verify compilation after wiring the plugin hook (no compile errors; one doc warning for `kill_process_tree`). Notes & follow-ups -- This is a pragmatic, short-term fix using shell/PowerShell helpers. We can consider a Rust-native implementation later for better portability and finer control. -- We should update any sidecar spawners (plugins or examples that call `Command::spawn()`) to call `app.handle().register_sidecar(child.id() as u32)` after spawning and `unregister_sidecar` when stopping the sidecar. -- Add an integration test that spawns a parent process which itself spawns a child, registers the parent PID, and asserts both are gone after cleanup. +- Implement `cleanup_before_exit` in the shell plugin (plugins-workspace repo). The shell plugin should be updated to drain any sidecar PID registries it manages and use `kill_process_tree` to ensure descendant processes are terminated. +- Update examples and any existing sidecar spawners to use the shell plugin or to call plugin-provided APIs instead of the removed `AppHandle::register_sidecar`/`unregister_sidecar`. +- Consider adding an integration test that validates sidecar shutdown via the shell plugin's cleanup hook. References diff --git a/crates/tauri/src/app.rs b/crates/tauri/src/app.rs index a8d699babcbd..97f8a323363e 100644 --- a/crates/tauri/src/app.rs +++ b/crates/tauri/src/app.rs @@ -501,13 +501,7 @@ impl AppHandle { Ok(()) } - pub fn register_sidecar(&self, pid: u32) { - self.manager.register_sidecar(pid); - } - pub fn unregister_sidecar(&self, pid: u32) { - self.manager.unregister_sidecar(pid); - } /// Removes the plugin with the given name. /// @@ -1045,12 +1039,13 @@ macro_rules! shared_app_impl { for (_, webview) in self.manager.webviews() { webview.resources_table().clear(); } - let sidecar_pids = self.manager.drain_sidecar_pids(); - for pid in sidecar_pids { - if let Err(e) = crate::process::kill_process_tree(pid) { - log::warn!("failed to kill sidecar pid {}: {}", pid, e); - } - } + // 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/manager/mod.rs b/crates/tauri/src/manager/mod.rs index 05f9b0b9846e..2126dd8957ce 100644 --- a/crates/tauri/src/manager/mod.rs +++ b/crates/tauri/src/manager/mod.rs @@ -4,7 +4,7 @@ use std::{ borrow::Cow, - collections::{HashMap, HashSet}, + collections::HashMap, fmt, sync::{atomic::AtomicBool, Arc, Mutex, MutexGuard}, }; @@ -214,7 +214,6 @@ pub struct AppManager { /// Application Resources Table pub(crate) resources_table: Arc>, - pub(crate) sidecar_pids: Arc>>, /// Runtime-generated invoke key. pub(crate) invoke_key: String, @@ -323,7 +322,6 @@ impl AppManager { pattern: Arc::new(context.pattern), plugin_global_api_scripts: Arc::new(context.plugin_global_api_scripts), resources_table: Arc::default(), - sidecar_pids: Arc::default(), invoke_key, channel_interceptor, restart_on_exit: AtomicBool::new(false), @@ -698,19 +696,6 @@ impl AppManager { pub(crate) fn invoke_key(&self) -> &str { &self.invoke_key } - - pub fn register_sidecar(&self, pid: u32) { - let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); - pids.insert(pid); - } - pub fn unregister_sidecar(&self, pid: u32) { - let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); - pids.remove(&pid); - } - pub fn drain_sidecar_pids(&self) -> Vec { - let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids"); - pids.drain().collect() - } } #[cfg(desktop)] diff --git a/crates/tauri/src/plugin.rs b/crates/tauri/src/plugin.rs index 9c7f9a1abaaf..f6f407d3221b 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 is guaranteed to run from the thread performing + /// the app cleanup/exit sequence. + #[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 227fe7ab8ec0..dbd99fdb9718 100644 --- a/crates/tauri/src/process.rs +++ b/crates/tauri/src/process.rs @@ -134,6 +134,8 @@ pub fn kill_process_tree(pid: u32) -> std::io::Result<()> { { use std::process::Command; + // Use PowerShell to recursively find and stop child processes, then stop the root. + // This mirrors the approach used elsewhere in the project (tauri-cli). 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 @@ -159,6 +161,9 @@ pub fn kill_process_tree(pid: u32) -> std::io::Result<()> { { 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 From 5b6c5497020e3842d5725db66b02eff1da460661 Mon Sep 17 00:00:00 2001 From: Sakina Farukh Ahemad Date: Wed, 12 Nov 2025 13:17:23 +0530 Subject: [PATCH 4/5] use taskkill /T /PID for sidecar termination --- crates/tauri/PR_DRAFT.md | 8 ++++++++ crates/tauri/src/process.rs | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/tauri/PR_DRAFT.md b/crates/tauri/PR_DRAFT.md index 0c59d8466566..b2bcadc699bb 100644 --- a/crates/tauri/PR_DRAFT.md +++ b/crates/tauri/PR_DRAFT.md @@ -41,6 +41,14 @@ Notes & follow-ups - Update examples and any existing sidecar spawners to use the shell plugin or to call plugin-provided APIs instead of the removed `AppHandle::register_sidecar`/`unregister_sidecar`. - Consider adding an integration test that validates sidecar shutdown via the shell plugin's cleanup hook. +- Windows implementation note + + - On Windows `kill_process_tree` now prefers the built-in `taskkill /T /PID /F` utility + which will terminate a process tree. If `taskkill` isn't available or returns a non-zero + exit status (for example due to permissions), the runtime falls back to a PowerShell-based + recursive traversal that mirrors the prior behavior. This makes termination more robust on + typical Windows environments while still preserving the previous fallback. + References - Fixes #14360 diff --git a/crates/tauri/src/process.rs b/crates/tauri/src/process.rs index dbd99fdb9718..8006552d24ee 100644 --- a/crates/tauri/src/process.rs +++ b/crates/tauri/src/process.rs @@ -129,13 +129,44 @@ 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. pub fn kill_process_tree(pid: u32) -> std::io::Result<()> { #[cfg(windows)] { use std::process::Command; - // Use PowerShell to recursively find and stop child processes, then stop the root. - // This mirrors the approach used elsewhere in the project (tauri-cli). + // 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 @@ -152,7 +183,7 @@ pub fn kill_process_tree(pid: u32) -> std::io::Result<()> { } else { Err(std::io::Error::new( std::io::ErrorKind::Other, - format!("powershell kill-tree failed with status: {}", status), + format!("kill-tree failed: powershell exited with status: {}", status), )) } } From 74c437b0fedb6dc26319517be480d95b0a6181fc Mon Sep 17 00:00:00 2001 From: Sakina Farukh Ahemad Date: Sat, 15 Nov 2025 16:30:16 +0530 Subject: [PATCH 5/5] feat(core): add cleanup_before_exit hook to TauriPlugin trait and docs --- .changes/use-plugin-cleanups-and-kill-tree.md | 14 +++++ crates/tauri/CHANGELOG.md | 11 ---- crates/tauri/PR_DRAFT.md | 54 ------------------- crates/tauri/src/plugin.rs | 4 +- crates/tauri/src/process.rs | 2 + 5 files changed, 18 insertions(+), 67 deletions(-) create mode 100644 .changes/use-plugin-cleanups-and-kill-tree.md delete mode 100644 crates/tauri/PR_DRAFT.md 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/CHANGELOG.md b/crates/tauri/CHANGELOG.md index 481c1d5c1e97..5dfa010859da 100644 --- a/crates/tauri/CHANGELOG.md +++ b/crates/tauri/CHANGELOG.md @@ -1,16 +1,5 @@ # Changelog -## \[Unreleased] - -### Enhancements - -- Added a best-effort process tree killer and plugin-level cleanup hook: - - `kill_process_tree(pid: u32)` helper (cross-platform, shell/PowerShell based). - - Plugin lifecycle hook `Plugin::cleanup_before_exit` and `PluginStore::cleanup_before_exit` — the runtime now delegates shutdown cleanup to plugins (for example the shell plugin) so they can manage sidecar lifecycle and process termination. - - Removed public convenience `AppHandle::register_sidecar` / `unregister_sidecar`: sidecar lifecycle and registration should be owned by the plugin responsible for spawning them. - - CLI dev-run integration attempts to kill sidecar descendant processes during terminate. - This ensures descendant processes spawned by sidecars are terminated while keeping process-control logic inside the plugin that owns the sidecar (Fixes #14360) - ## \[2.9.2] ### Bug Fixes diff --git a/crates/tauri/PR_DRAFT.md b/crates/tauri/PR_DRAFT.md deleted file mode 100644 index b2bcadc699bb..000000000000 --- a/crates/tauri/PR_DRAFT.md +++ /dev/null @@ -1,54 +0,0 @@ -Title: Use plugin cleanup hooks for sidecar shutdown; add kill-tree helper (Fixes #14360) - -Summary - -This PR centralizes process-kill helpers in the runtime and moves responsibility for sidecar shutdown to plugins by adding a plugin-level cleanup hook. The core runtime no longer contains hardcoded sidecar draining logic; instead it calls `Plugin::cleanup_before_exit` so plugins (for example, the shell plugin) can take care of stopping sidecars and terminating process trees. - -What I changed - -- crates/tauri/src/process.rs - - Added `pub fn kill_process_tree(pid: u32) -> std::io::Result<()>` which invokes platform-specific shell/PowerShell snippets to terminate a process tree (best-effort, no new deps). - -- crates/tauri/src/plugin.rs - - Added a plugin lifecycle hook `fn cleanup_before_exit(&mut self, app: &AppHandle) {}` with a default no-op. - - Added `PluginStore::cleanup_before_exit(&mut self, app: &AppHandle)` which invokes the hook for every registered plugin. - -- crates/tauri/src/app.rs - - Replaced the hardcoded sidecar PID draining and kill logic in the app shutdown path with a call to `plugins.lock().unwrap().cleanup_before_exit(self.app_handle())` so plugins perform shutdown work. - - Removed the previous `AppHandle::register_sidecar` / `unregister_sidecar` convenience methods from the public API (spawners should migrate to plugin-managed registries). - -- Note on migration / shell plugin responsibilities - - The shell plugin (which lives in the plugins workspace) should implement `cleanup_before_exit` and perform any sidecar shutdown it needs. A minimal implementation is to drain the manager's sidecar registry and call `kill_process_tree` for each PID, e.g.: - - ```text - fn cleanup_before_exit(&mut self, app: &AppHandle) { - let pids = app.manager.drain_sidecar_pids(); - for pid in pids { - let _ = tauri::process::kill_process_tree(pid); - } - } - ``` - - This keeps process-management details inside the shell plugin (owner of sidecar lifecycle) and makes the runtime more extensible. - -Testing done - -- Ran `cargo check -p tauri` locally to verify compilation after wiring the plugin hook (no compile errors; one doc warning for `kill_process_tree`). - -Notes & follow-ups - -- Implement `cleanup_before_exit` in the shell plugin (plugins-workspace repo). The shell plugin should be updated to drain any sidecar PID registries it manages and use `kill_process_tree` to ensure descendant processes are terminated. -- Update examples and any existing sidecar spawners to use the shell plugin or to call plugin-provided APIs instead of the removed `AppHandle::register_sidecar`/`unregister_sidecar`. -- Consider adding an integration test that validates sidecar shutdown via the shell plugin's cleanup hook. - -- Windows implementation note - - - On Windows `kill_process_tree` now prefers the built-in `taskkill /T /PID /F` utility - which will terminate a process tree. If `taskkill` isn't available or returns a non-zero - exit status (for example due to permissions), the runtime falls back to a PowerShell-based - recursive traversal that mirrors the prior behavior. This makes termination more robust on - typical Windows environments while still preserving the previous fallback. - -References - -- Fixes #14360 diff --git a/crates/tauri/src/plugin.rs b/crates/tauri/src/plugin.rs index f6f407d3221b..114f700f78a3 100644 --- a/crates/tauri/src/plugin.rs +++ b/crates/tauri/src/plugin.rs @@ -108,8 +108,8 @@ pub trait Plugin: Send { /// /// 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 is guaranteed to run from the thread performing - /// the app cleanup/exit sequence. + /// 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) {} diff --git a/crates/tauri/src/process.rs b/crates/tauri/src/process.rs index 8006552d24ee..ed357caa9f26 100644 --- a/crates/tauri/src/process.rs +++ b/crates/tauri/src/process.rs @@ -144,6 +144,8 @@ fn restart_macos_app(current_binary: &std::path::Path, env: &Env) { /// 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)] {