Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
35 changes: 35 additions & 0 deletions crates/tauri-cli/src/interface/rust/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
10 changes: 10 additions & 0 deletions crates/tauri/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 35 additions & 0 deletions crates/tauri/PR_DRAFT.md
Original file line number Diff line number Diff line change
@@ -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<Mutex<HashSet<u32>>>` 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
14 changes: 14 additions & 0 deletions crates/tauri/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,14 @@ impl<R: Runtime> AppHandle<R> {
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.
///
/// # Examples
Expand Down Expand Up @@ -1037,6 +1045,12 @@ 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);
}
}
}

/// Gets the invoke key that must be referenced when using [`crate::webview::InvokeRequest`].
Expand Down
17 changes: 16 additions & 1 deletion crates/tauri/src/manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use std::{
borrow::Cow,
collections::HashMap,
collections::{HashMap, HashSet},
fmt,
sync::{atomic::AtomicBool, Arc, Mutex, MutexGuard},
};
Expand Down Expand Up @@ -214,6 +214,7 @@ pub struct AppManager<R: Runtime> {

/// Application Resources Table
pub(crate) resources_table: Arc<Mutex<ResourceTable>>,
pub(crate) sidecar_pids: Arc<Mutex<HashSet<u32>>>,

/// Runtime-generated invoke key.
pub(crate) invoke_key: String,
Expand Down Expand Up @@ -322,6 +323,7 @@ impl<R: Runtime> AppManager<R> {
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),
Expand Down Expand Up @@ -696,6 +698,19 @@ impl<R: Runtime> AppManager<R> {
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<u32> {
let mut pids = self.sidecar_pids.lock().expect("poisoned sidecar_pids");
pids.drain().collect()
}
}

#[cfg(desktop)]
Expand Down
57 changes: 57 additions & 0 deletions crates/tauri/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,60 @@ fn restart_macos_app(current_binary: &std::path::Path, env: &Env) {
}
}
}

pub fn kill_process_tree(pid: u32) -> std::io::Result<()> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is a generic function and not bound to this app instance imo this should be moved into https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/process (and in theory that plugin's exit and restart should probably be part of the app core plugin instead but that's a discussion for another day).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be okay if I leave a TODO for now and move it in a follow-up PR?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we at least have to decide how to proceed here because once this is added in tauri we cannot remove it without a major release since removing apis is a breaking change.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I move kill_process_tree to the process plugin now as you suggested? I’ll update this PR to avoid a future breaking change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think that would make sense, yes. we can always move it back if needed (that way around works)

#[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 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;

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),
))
}
}
}

Loading