diff --git a/.changes/shell-process-group.md b/.changes/shell-process-group.md
new file mode 100644
index 000000000..fa8c4da66
--- /dev/null
+++ b/.changes/shell-process-group.md
@@ -0,0 +1,6 @@
+---
+"shell": minor
+"shell-js": minor
+---
+
+Add `processGroup` option to spawn commands in a new process group (POSIX) or job object (Windows), allowing the entire process tree to be killed when calling `kill()` on the child process.
diff --git a/Cargo.lock b/Cargo.lock
index d350f6938..824f5422c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5845,12 +5845,12 @@ dependencies = [
[[package]]
name = "shared_child"
-version = "1.0.1"
+version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09fa9338aed9a1df411814a5b2252f7cd206c55ae9bf2fa763f8de84603aa60c"
+checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -7018,6 +7018,7 @@ name = "tauri-plugin-shell"
version = "2.4.1"
dependencies = [
"encoding_rs",
+ "libc",
"log",
"open",
"os_pipe",
@@ -7030,6 +7031,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.12",
"tokio",
+ "windows-sys 0.60.2",
]
[[package]]
diff --git a/examples/api/src/views/Shell.svelte b/examples/api/src/views/Shell.svelte
index faaa8c4cc..84a36ac05 100644
--- a/examples/api/src/views/Shell.svelte
+++ b/examples/api/src/views/Shell.svelte
@@ -11,6 +11,7 @@
let cwd = null;
let env = "SOMETHING=value ANOTHER=2";
let encoding = "";
+ let processGroup = false;
let stdin = "";
let child;
@@ -30,6 +31,7 @@
cwd: cwd || null,
env: _getEnv(),
encoding: encoding || undefined,
+ processGroup,
});
command.on("close", (data) => {
@@ -47,6 +49,9 @@
.spawn()
.then((c) => {
child = c;
+ onMessage(
+ `spawned pid ${c.pid} (processGroup: ${processGroup ? "on" : "off"})`
+ );
})
.catch(onMessage);
}
@@ -88,6 +93,14 @@
placeholder="Environment variables"
/>
+
+
+
+
diff --git a/plugins/shell/Cargo.toml b/plugins/shell/Cargo.toml
index ad7a1e0b3..5e4caee5e 100644
--- a/plugins/shell/Cargo.toml
+++ b/plugins/shell/Cargo.toml
@@ -39,11 +39,23 @@ tauri = { workspace = true }
tokio = { version = "1", features = ["time"] }
log = { workspace = true }
thiserror = { workspace = true }
-shared_child = "1"
regex = "1"
open = { version = "5", features = ["shellexecute-on-windows"] }
encoding_rs = "0.8"
os_pipe = "1"
+shared_child = { version = "1", default-features = false }
+
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
+[target.'cfg(windows)'.dependencies]
+windows-sys = { version = "0.60", features = [
+ "Win32_Foundation",
+ "Win32_Security",
+ "Win32_System_Threading",
+ "Win32_System_JobObjects",
+ "Win32_System_Diagnostics_ToolHelp",
+] }
[target.'cfg(target_os = "ios")'.dependencies]
tauri = { workspace = true, features = ["wry"] }
diff --git a/plugins/shell/guest-js/index.ts b/plugins/shell/guest-js/index.ts
index afac3026e..f93c00196 100644
--- a/plugins/shell/guest-js/index.ts
+++ b/plugins/shell/guest-js/index.ts
@@ -81,6 +81,16 @@ interface SpawnOptions {
* @since 2.0.0
* */
encoding?: string
+ /**
+ * When enabled, spawns the child process in its own process group (POSIX)
+ * or job object (Windows). This allows killing the entire process tree
+ * when calling `kill()` on the child process.
+ *
+ * Useful for programs that spawn child processes, such as PyInstaller wrappers.
+ *
+ * Defaults to `false`.
+ */
+ processGroup?: boolean
}
/** @ignore */
diff --git a/plugins/shell/src/commands.rs b/plugins/shell/src/commands.rs
index 2d1a73edf..6ca0282d5 100644
--- a/plugins/shell/src/commands.rs
+++ b/plugins/shell/src/commands.rs
@@ -88,6 +88,10 @@ pub struct CommandOptions {
env: Option>,
// Character encoding for stdout/stderr
encoding: Option,
+ // Spawn the child in a new process group (POSIX) or job object (Windows).
+ // When enabled, killing the child also kills all processes in the group.
+ #[serde(default)]
+ process_group: bool,
}
#[allow(clippy::unnecessary_wraps)]
@@ -154,6 +158,9 @@ fn prepare_cmd(
} else {
command = command.env_clear();
}
+ if options.process_group {
+ command = command.set_process_group(true);
+ }
let encoding = match options.encoding {
Option::None => EncodingWrapper::Text(None),
@@ -296,13 +303,16 @@ pub fn stdin_write(
Ok(())
}
+// Async so waiting for the child to exit doesn't block the main thread.
#[tauri::command]
-pub fn kill(
+pub async fn kill(
_window: Window,
shell: State<'_, Shell>,
pid: ChildId,
) -> crate::Result<()> {
- if let Some(child) = shell.children.lock().unwrap().remove(&pid) {
+ // Release the lock before killing, which waits for the child to exit.
+ let child = shell.children.lock().unwrap().remove(&pid);
+ if let Some(child) = child {
child.kill()?;
}
Ok(())
diff --git a/plugins/shell/src/process/job_object.rs b/plugins/shell/src/process/job_object.rs
new file mode 100644
index 000000000..dc6682cdb
--- /dev/null
+++ b/plugins/shell/src/process/job_object.rs
@@ -0,0 +1,90 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+//! Job object backing `process_group` on Windows, adapted from
+//! [process-wrap](https://github.com/watchexec/process-wrap) (Apache-2.0 OR MIT).
+
+use std::{io, mem, os::windows::io::AsRawHandle, process::Child, ptr};
+
+use windows_sys::Win32::{
+ Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE},
+ System::{
+ Diagnostics::ToolHelp::{
+ CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
+ },
+ JobObjects::{AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject},
+ Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME},
+ },
+};
+
+/// Owned job object handle. Closing it leaves the processes in the job running.
+#[derive(Debug)]
+pub(crate) struct JobObject(HANDLE);
+
+// SAFETY: the handle is only ever passed to thread-safe Win32 calls.
+unsafe impl Send for JobObject {}
+unsafe impl Sync for JobObject {}
+
+impl JobObject {
+ /// Creates a job object, assigns the `CREATE_SUSPENDED` `child` to it and resumes the child.
+ pub(crate) fn assign(child: &Child) -> io::Result {
+ let job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
+ if job.is_null() {
+ return Err(io::Error::last_os_error());
+ }
+ let job = Self(job);
+ if unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle()) } == 0 {
+ return Err(io::Error::last_os_error());
+ }
+ resume_threads(child.id())?;
+ Ok(job)
+ }
+
+ pub(crate) fn terminate(&self) -> io::Result<()> {
+ if unsafe { TerminateJobObject(self.0, 1) } == 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+}
+
+impl Drop for JobObject {
+ fn drop(&mut self) {
+ unsafe { CloseHandle(self.0) };
+ }
+}
+
+/// `std` closes the main thread handle right after `CreateProcess`, so a suspended
+/// child can only be resumed by looking its threads up again.
+fn resume_threads(pid: u32) -> io::Result<()> {
+ let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
+ if snapshot == INVALID_HANDLE_VALUE {
+ return Err(io::Error::last_os_error());
+ }
+ let result = resume_snapshot_threads(snapshot, pid);
+ unsafe { CloseHandle(snapshot) };
+ result
+}
+
+fn resume_snapshot_threads(snapshot: HANDLE, pid: u32) -> io::Result<()> {
+ let mut entry: THREADENTRY32 = unsafe { mem::zeroed() };
+ entry.dwSize = mem::size_of::() as u32;
+ let mut found = unsafe { Thread32First(snapshot, &mut entry) } != 0;
+ while found {
+ if entry.th32OwnerProcessID == pid {
+ let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
+ if thread.is_null() {
+ return Err(io::Error::last_os_error());
+ }
+ if unsafe { ResumeThread(thread) } == u32::MAX {
+ let err = io::Error::last_os_error();
+ unsafe { CloseHandle(thread) };
+ return Err(err);
+ }
+ unsafe { CloseHandle(thread) };
+ }
+ found = unsafe { Thread32Next(snapshot, &mut entry) } != 0;
+ }
+ Ok(())
+}
diff --git a/plugins/shell/src/process/mod.rs b/plugins/shell/src/process/mod.rs
index cb34f2a39..26b0c2a30 100644
--- a/plugins/shell/src/process/mod.rs
+++ b/plugins/shell/src/process/mod.rs
@@ -12,12 +12,13 @@ use std::{
};
#[cfg(unix)]
-use std::os::unix::process::ExitStatusExt;
+use std::os::unix::process::{CommandExt, ExitStatusExt};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
-const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
+
const NEWLINE_BYTE: u8 = b'\n';
use tauri::async_runtime::{Receiver, Sender, block_on as block_on_task, channel};
@@ -28,6 +29,9 @@ use serde::Serialize;
use shared_child::SharedChild;
use tauri::utils::platform;
+#[cfg(windows)]
+mod job_object;
+
/// Payload for the [`CommandEvent::Terminated`] command event.
#[derive(Debug, Clone, Serialize)]
pub struct TerminatedPayload {
@@ -58,6 +62,7 @@ pub enum CommandEvent {
pub struct Command {
cmd: StdCommand,
raw_out: bool,
+ process_group: bool,
}
/// Spawned child process.
@@ -65,6 +70,10 @@ pub struct Command {
pub struct CommandChild {
inner: Arc,
stdin_writer: PipeWriter,
+ #[cfg(unix)]
+ process_group: bool,
+ #[cfg(windows)]
+ job: Option,
}
impl CommandChild {
@@ -74,9 +83,34 @@ impl CommandChild {
Ok(())
}
- /// Sends a kill signal to the child.
+ /// Sends a kill signal to the child and waits for it to exit.
+ /// With `process_group` enabled this kills the whole process group (POSIX) or job object (Windows).
pub fn kill(self) -> crate::Result<()> {
- self.inner.kill()?;
+ // No early return when the child already exited: the rest of its
+ // process group or job object may still be running.
+ #[cfg(unix)]
+ if self.process_group {
+ // The pgid can't be reused while the group has members, so this is
+ // safe even after the group leader was reaped.
+ let pgid = self.inner.id() as libc::pid_t;
+ if unsafe { libc::killpg(pgid, libc::SIGKILL) } != 0 {
+ let err = std::io::Error::last_os_error();
+ // ESRCH: every process in the group already exited.
+ if err.raw_os_error() != Some(libc::ESRCH) {
+ return Err(err.into());
+ }
+ }
+ } else {
+ self.inner.kill()?;
+ }
+
+ #[cfg(windows)]
+ match &self.job {
+ Some(job) => job.terminate()?,
+ None => self.inner.kill()?,
+ }
+
+ self.inner.wait()?;
Ok(())
}
@@ -175,6 +209,7 @@ impl Command {
Self {
cmd: command,
raw_out: false,
+ process_group: false,
}
}
@@ -243,6 +278,16 @@ impl Command {
self
}
+ /// Configures the command to spawn in a new process group (POSIX) or job object (Windows).
+ ///
+ /// When enabled, killing the child process will also kill all processes in the group,
+ /// which is useful for programs that spawn child processes (e.g. PyInstaller wrappers).
+ #[must_use]
+ pub fn set_process_group(mut self, process_group: bool) -> Self {
+ self.process_group = process_group;
+ self
+ }
+
/// Spawns the command.
///
/// # Examples
@@ -304,6 +349,7 @@ impl Command {
/// ```
pub fn spawn(self) -> crate::Result<(Receiver, CommandChild)> {
let raw = self.raw_out;
+ let process_group = self.process_group;
let mut command: StdCommand = self.into();
let (stdout_reader, stdout_writer) = pipe()?;
let (stderr_reader, stderr_writer) = pipe()?;
@@ -312,8 +358,35 @@ impl Command {
command.stderr(stderr_writer);
command.stdin(stdin_reader);
- let shared_child = SharedChild::spawn(&mut command)?;
- let child = Arc::new(shared_child);
+ #[cfg(unix)]
+ if process_group {
+ command.process_group(0);
+ }
+ #[cfg(windows)]
+ if process_group {
+ // Start suspended so nothing can be spawned before the child is in the job.
+ command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
+ }
+
+ let child = command.spawn()?;
+
+ #[cfg(windows)]
+ let job = if process_group {
+ match job_object::JobObject::assign(&child) {
+ Ok(job) => Some(job),
+ Err(e) => {
+ // Don't leave the suspended child behind.
+ let mut child = child;
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(e.into());
+ }
+ }
+ } else {
+ None
+ };
+
+ let child = Arc::new(SharedChild::new(child)?);
let child_ = child.clone();
let (tx, rx) = channel(1);
@@ -348,6 +421,10 @@ impl Command {
CommandChild {
inner: child,
stdin_writer,
+ #[cfg(unix)]
+ process_group,
+ #[cfg(windows)]
+ job,
},
))
}
@@ -640,4 +717,278 @@ mod tests {
"cat: test/: Is a directory\n\n"
);
}
+
+ #[cfg(not(windows))]
+ #[test]
+ fn test_cmd_spawn_process_group_output() {
+ let cmd = Command::new("cat")
+ .args(["test/test.txt"])
+ .set_process_group(true);
+ let (mut rx, _) = cmd.spawn().unwrap();
+
+ tauri::async_runtime::block_on(async move {
+ while let Some(event) = rx.recv().await {
+ match event {
+ CommandEvent::Terminated(payload) => {
+ assert_eq!(payload.code, Some(0));
+ }
+ CommandEvent::Stdout(line) => {
+ assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!");
+ }
+ _ => {}
+ }
+ }
+ });
+ }
+
+ #[cfg(not(windows))]
+ #[test]
+ fn test_cmd_process_group_kill() {
+ // Spawn a shell that runs a sleep command as a child process.
+ // With process_group enabled, killing the parent should also kill the child.
+ let cmd = Command::new("sh")
+ .args(["-c", "sleep 60"])
+ .set_process_group(true);
+ let (mut rx, child) = cmd.spawn().unwrap();
+ let pid = child.pid();
+
+ // Verify the process is running
+ let ret = unsafe { libc::kill(pid as i32, 0) };
+ assert_eq!(ret, 0, "process should be running");
+
+ // Kill the process group
+ child.kill().unwrap();
+
+ tauri::async_runtime::block_on(async move {
+ while let Some(event) = rx.recv().await {
+ if let CommandEvent::Terminated(payload) = event {
+ // Process was killed by signal, so code is None and signal is Some
+ assert!(payload.code.is_none() || payload.signal.is_some());
+ break;
+ }
+ }
+ });
+
+ // Verify the process group is gone
+ let ret = unsafe { libc::killpg(pid as i32, 0) };
+ assert_ne!(ret, 0, "process group should no longer exist");
+ }
+
+ #[test]
+ fn test_cmd_process_group_output() {
+ // Hangs on Windows if the suspended child is never resumed.
+ #[cfg(not(windows))]
+ let cmd = Command::new("cat").args(["test/test.txt"]);
+ #[cfg(windows)]
+ let cmd = Command::new("cmd").args(["/C", "type test\\test.txt"]);
+
+ let output = tauri::async_runtime::block_on(cmd.set_process_group(true).output()).unwrap();
+
+ assert!(output.status.success());
+ assert_eq!(String::from_utf8(output.stderr).unwrap(), "");
+ assert_eq!(
+ String::from_utf8(output.stdout).unwrap().trim(),
+ "This is a test doc!"
+ );
+ }
+
+ #[test]
+ fn test_cmd_kill_after_exit() {
+ for process_group in [false, true] {
+ #[cfg(not(windows))]
+ let cmd = Command::new("true");
+ #[cfg(windows)]
+ let cmd = Command::new("cmd").args(["/C", "exit 0"]);
+ let (rx, child) = cmd.set_process_group(process_group).spawn().unwrap();
+ wait_for_terminated(rx);
+ child.kill().unwrap();
+ }
+ }
+
+ /// Runs `test/