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/