Files
tauri-plugins-workspace/plugins/shell/src/process/mod.rs
T
TungliesandLucas Nogueira 738c323cfc feat(command): implement Drop for CommandChild with kill_on_drop option (#3149)
* feat(command): implement Drop for CommandChild with kill_on_drop option

* fmt

* log

* merge fixes, docs

* clippy

* Apply suggestion from @lucasfernog [skip ci]

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
2026-10-16 09:00:00 -03:00

1091 lines
35 KiB
Rust

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
ffi::OsStr,
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::{Command as StdCommand, Stdio},
sync::Arc,
thread::{JoinHandle, spawn},
};
#[cfg(unix)]
use std::os::unix::process::{CommandExt, ExitStatusExt};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
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};
pub use encoding_rs::Encoding;
use os_pipe::{PipeReader, PipeWriter, pipe};
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 {
/// Exit code of the process.
pub code: Option<i32>,
/// If the process was terminated by a signal, represents that signal.
pub signal: Option<i32>,
}
/// A event sent to the command callback.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum CommandEvent {
/// If configured for raw output, all bytes written to stderr.
/// Otherwise, bytes until a newline (\n) or carriage return (\r) is found.
Stderr(Vec<u8>),
/// If configured for raw output, all bytes written to stdout.
/// Otherwise, bytes until a newline (\n) or carriage return (\r) is found.
Stdout(Vec<u8>),
/// An error happened waiting for the command to finish or converting the stdout/stderr bytes to a UTF-8 string.
Error(String),
/// Command process terminated.
Terminated(TerminatedPayload),
}
/// The type to spawn commands.
#[derive(Debug)]
pub struct Command {
cmd: StdCommand,
raw_out: bool,
process_group: bool,
kill_on_drop: bool,
}
/// Spawned child process.
#[derive(Debug)]
pub struct CommandChild {
inner: Arc<SharedChild>,
stdin_writer: PipeWriter,
#[cfg(unix)]
process_group: bool,
#[cfg(windows)]
job: Option<job_object::JobObject>,
kill_on_drop: bool,
}
impl CommandChild {
/// Writes to process stdin.
pub fn write(&mut self, buf: &[u8]) -> crate::Result<()> {
self.stdin_writer.write_all(buf)?;
Ok(())
}
/// 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(mut self) -> crate::Result<()> {
self.send_kill()?;
// Already killed, so `Drop` must not do it again.
self.kill_on_drop = false;
self.inner.wait()?;
Ok(())
}
/// Sends the kill signal without waiting for the child to exit.
fn send_kill(&self) -> crate::Result<()> {
// 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()?,
}
Ok(())
}
/// Returns the process pid.
pub fn pid(&self) -> u32 {
self.inner.id()
}
}
impl Drop for CommandChild {
/// Kills the child process (or its process group / job object) if `kill_on_drop` is enabled.
fn drop(&mut self) {
if self.kill_on_drop
&& let Err(e) = self.send_kill()
{
log::error!("Failed to kill child process {}: {}", self.inner.id(), e);
}
}
}
/// Describes the result of a process after it has terminated.
#[derive(Debug)]
pub struct ExitStatus {
// This field is intentionally left private.
// See: https://github.com/tauri-apps/plugins-workspace/pull/3115.
code: Option<i32>,
}
impl ExitStatus {
/// Returns the exit code of the process, if any.
pub fn code(&self) -> Option<i32> {
self.code
}
/// Returns true if exit status is zero. Signal termination is not considered a success, and success is defined as a zero exit status.
pub fn success(&self) -> bool {
self.code == Some(0)
}
}
/// The output of a finished process.
#[derive(Debug)]
pub struct Output {
/// The status (exit code) of the process.
pub status: ExitStatus,
/// The data that the process wrote to stdout.
pub stdout: Vec<u8>,
/// The data that the process wrote to stderr.
pub stderr: Vec<u8>,
}
fn relative_command_path(command: &Path) -> crate::Result<PathBuf> {
let exe_path = platform::current_exe()?;
let exe_dir = exe_path
.parent()
.ok_or(crate::Error::CurrentExeHasNoParent)?;
// If a test is being run, the executable is in the "deps" directory, so we need to go up one level.
let base_dir = if exe_dir.ends_with("deps") {
exe_dir.parent().unwrap_or(exe_dir)
} else {
exe_dir
};
let mut command_path = base_dir.join(command);
#[cfg(windows)]
{
let already_exe = command_path.extension().is_some_and(|ext| ext == "exe");
if !already_exe {
// do not use with_extension to retain dots in the command filename
command_path.as_mut_os_string().push(".exe");
}
}
#[cfg(not(windows))]
{
if command_path.extension().is_some_and(|ext| ext == "exe") {
command_path.set_extension("");
}
}
Ok(command_path)
}
impl From<Command> for StdCommand {
fn from(cmd: Command) -> StdCommand {
cmd.cmd
}
}
impl Command {
pub(crate) fn new<S: AsRef<OsStr>>(program: S) -> Self {
log::debug!(
"Creating sidecar {}",
program.as_ref().to_str().unwrap_or("")
);
let mut command = StdCommand::new(program);
command.stdout(Stdio::piped());
command.stdin(Stdio::piped());
command.stderr(Stdio::piped());
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
Self {
cmd: command,
raw_out: false,
process_group: false,
kill_on_drop: false,
}
}
pub(crate) fn new_sidecar<S: AsRef<Path>>(program: S) -> crate::Result<Self> {
Ok(Self::new(relative_command_path(program.as_ref())?))
}
/// Appends an argument to the command.
#[must_use]
pub fn arg<S: AsRef<OsStr>>(mut self, arg: S) -> Self {
self.cmd.arg(arg);
self
}
/// Appends arguments to the command.
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.cmd.args(args);
self
}
/// Clears the entire environment map for the child process.
#[must_use]
pub fn env_clear(mut self) -> Self {
self.cmd.env_clear();
self
}
/// Inserts or updates an explicit environment variable mapping.
#[must_use]
pub fn env<K, V>(mut self, key: K, value: V) -> Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.cmd.env(key, value);
self
}
/// Adds or updates multiple environment variable mappings.
#[must_use]
pub fn envs<I, K, V>(mut self, envs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.cmd.envs(envs);
self
}
/// Sets the working directory for the child process.
#[must_use]
pub fn current_dir<P: AsRef<Path>>(mut self, current_dir: P) -> Self {
self.cmd.current_dir(current_dir);
self
}
/// Configures the reader to output bytes from the child process exactly as received
pub fn set_raw_out(mut self, raw_out: bool) -> Self {
self.raw_out = raw_out;
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
}
/// Configures whether the child process is killed when its [`CommandChild`] is dropped.
/// Defaults to `false`, so a dropped [`CommandChild`] leaves the process running.
///
/// The kill works like [`CommandChild::kill`] but does not wait for the process to exit.
/// Without [`Command::set_process_group`] only the child itself is killed, and any
/// processes it spawned keep running; enable it to kill the whole process group (POSIX)
/// or job object (Windows).
///
/// This has no effect on processes spawned from the JavaScript API; the plugin kills those when the app exits.
#[must_use]
pub fn kill_on_drop(mut self, kill_on_drop: bool) -> Self {
self.kill_on_drop = kill_on_drop;
self
}
/// Spawns the command.
///
/// # Examples
///
/// ```rust,no_run
/// use tauri_plugin_shell::{process::CommandEvent, ShellExt};
/// tauri::Builder::default()
/// .setup(|app| {
/// let handle = app.handle().clone();
/// tauri::async_runtime::spawn(async move {
/// let (mut rx, mut child) = handle
/// .shell()
/// .command("cargo")
/// .args(["tauri", "dev"])
/// .spawn()
/// .expect("Failed to spawn cargo");
///
/// let mut i = 0;
/// while let Some(event) = rx.recv().await {
/// if let CommandEvent::Stdout(line) = event {
/// println!("got: {}", String::from_utf8(line).unwrap());
/// i += 1;
/// if i == 4 {
/// child.write("message from Rust\n".as_bytes()).unwrap();
/// i = 0;
/// }
/// }
/// }
/// });
/// Ok(())
/// });
/// ```
///
/// Depending on the command you spawn, it might output in a specific encoding, to parse the output lines in this case:
///
/// ```rust,no_run
/// use tauri_plugin_shell::{process::{CommandEvent, Encoding}, ShellExt};
/// tauri::Builder::default()
/// .setup(|app| {
/// let handle = app.handle().clone();
/// tauri::async_runtime::spawn(async move {
/// let (mut rx, mut child) = handle
/// .shell()
/// .command("some-program")
/// .arg("some-arg")
/// .spawn()
/// .expect("Failed to spawn some-program");
///
/// let encoding = Encoding::for_label(b"windows-1252").unwrap();
/// while let Some(event) = rx.recv().await {
/// if let CommandEvent::Stdout(line) = event {
/// let (decoded, _, _) = encoding.decode(&line);
/// println!("got: {decoded}");
/// }
/// }
/// });
/// Ok(())
/// });
/// ```
pub fn spawn(self) -> crate::Result<(Receiver<CommandEvent>, CommandChild)> {
let raw = self.raw_out;
let process_group = self.process_group;
let kill_on_drop = self.kill_on_drop;
let mut command: StdCommand = self.into();
let (stdout_reader, stdout_writer) = pipe()?;
let (stderr_reader, stderr_writer) = pipe()?;
let (stdin_reader, stdin_writer) = pipe()?;
command.stdout(stdout_writer);
command.stderr(stderr_writer);
command.stdin(stdin_reader);
#[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);
let stdout_thread = spawn_pipe_reader(tx.clone(), stdout_reader, CommandEvent::Stdout, raw);
let stderr_thread = spawn_pipe_reader(tx.clone(), stderr_reader, CommandEvent::Stderr, raw);
spawn(move || {
let status = child_.wait();
// `Terminated` must be the last event: wait for the readers to drain the pipes,
// since a short-lived child can exit before they have read anything.
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let _ = match status {
Ok(status) => block_on_task(async move {
tx.send(CommandEvent::Terminated(TerminatedPayload {
code: status.code(),
#[cfg(windows)]
signal: None,
#[cfg(unix)]
signal: status.signal(),
}))
.await
}),
Err(e) => {
block_on_task(async move { tx.send(CommandEvent::Error(e.to_string())).await })
}
};
});
Ok((
rx,
CommandChild {
inner: child,
stdin_writer,
#[cfg(unix)]
process_group,
#[cfg(windows)]
job,
kill_on_drop,
},
))
}
/// Executes a command as a child process, waiting for it to finish and collecting its exit status.
/// Stdin, stdout and stderr are ignored.
///
/// # Examples
/// ```rust,no_run
/// use tauri_plugin_shell::ShellExt;
/// tauri::Builder::default()
/// .setup(|app| {
/// let status = tauri::async_runtime::block_on(async move { app.shell().command("which").args(["ls"]).status().await.unwrap() });
/// println!("`which` finished with status: {:?}", status.code());
/// Ok(())
/// });
/// ```
pub async fn status(self) -> crate::Result<ExitStatus> {
let (mut rx, _child) = self.spawn()?;
let mut code = None;
#[allow(clippy::collapsible_match)]
while let Some(event) = rx.recv().await {
if let CommandEvent::Terminated(payload) = event {
code = payload.code;
}
}
Ok(ExitStatus { code })
}
/// Executes the command as a child process, waiting for it to finish and collecting all of its output.
/// Stdin is ignored.
///
/// # Examples
///
/// ```rust,no_run
/// use tauri_plugin_shell::ShellExt;
/// tauri::Builder::default()
/// .setup(|app| {
/// let output = tauri::async_runtime::block_on(async move { app.shell().command("echo").args(["TAURI"]).output().await.unwrap() });
/// assert!(output.status.success());
/// assert_eq!(String::from_utf8(output.stdout).unwrap(), "TAURI");
/// Ok(())
/// });
/// ```
pub async fn output(self) -> crate::Result<Output> {
let (mut rx, _child) = self.spawn()?;
let mut code = None;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Terminated(payload) => {
code = payload.code;
}
CommandEvent::Stdout(line) => {
stdout.extend(line);
stdout.push(NEWLINE_BYTE);
}
CommandEvent::Stderr(line) => {
stderr.extend(line);
stderr.push(NEWLINE_BYTE);
}
CommandEvent::Error(_) => {}
}
}
Ok(Output {
status: ExitStatus { code },
stdout,
stderr,
})
}
}
fn read_raw_bytes<F: Fn(Vec<u8>) -> CommandEvent + Send + Copy + 'static>(
mut reader: BufReader<PipeReader>,
tx: Sender<CommandEvent>,
wrapper: F,
) {
loop {
let result = reader.fill_buf();
match result {
Ok(buf) => {
let length = buf.len();
if length == 0 {
break;
}
let tx_ = tx.clone();
let _ = block_on_task(async move { tx_.send(wrapper(buf.to_vec())).await });
reader.consume(length);
}
Err(e) => {
let tx_ = tx.clone();
let _ = block_on_task(
async move { tx_.send(CommandEvent::Error(e.to_string())).await },
);
}
}
}
}
fn read_line<F: Fn(Vec<u8>) -> CommandEvent + Send + Copy + 'static>(
mut reader: BufReader<PipeReader>,
tx: Sender<CommandEvent>,
wrapper: F,
) {
loop {
let mut buf = Vec::new();
match tauri::utils::io::read_line(&mut reader, &mut buf) {
Ok(n) => {
if n == 0 {
break;
}
let tx_ = tx.clone();
let _ = block_on_task(async move { tx_.send(wrapper(buf)).await });
}
Err(e) => {
let _ =
block_on_task(async move { tx.send(CommandEvent::Error(e.to_string())).await });
break;
}
}
}
}
fn spawn_pipe_reader<F: Fn(Vec<u8>) -> CommandEvent + Send + Copy + 'static>(
tx: Sender<CommandEvent>,
pipe_reader: PipeReader,
wrapper: F,
raw_out: bool,
) -> JoinHandle<()> {
spawn(move || {
let reader = BufReader::new(pipe_reader);
if raw_out {
read_raw_bytes(reader, tx, wrapper);
} else {
read_line(reader, tx, wrapper);
}
})
}
// tests for the commands functions.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_command_path_resolves() {
let cwd_parent = platform::current_exe()
.unwrap()
.parent()
.unwrap()
.parent() // Go up once more to get out of the "deps" directory
.unwrap()
.to_owned();
assert_eq!(
relative_command_path(Path::new("Tauri.Example")).unwrap(),
cwd_parent.join(if cfg!(windows) {
"Tauri.Example.exe"
} else {
"Tauri.Example"
})
);
assert_eq!(
relative_command_path(Path::new("Tauri.Example.exe")).unwrap(),
cwd_parent.join(if cfg!(windows) {
"Tauri.Example.exe"
} else {
"Tauri.Example"
})
);
}
#[cfg(not(windows))]
#[test]
fn test_cmd_spawn_output() {
let cmd = Command::new("cat").args(["test/test.txt"]);
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_spawn_raw_output() {
let cmd = Command::new("cat").args(["test/test.txt"]);
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]
// test the failure case
fn test_cmd_spawn_fail() {
let cmd = Command::new("cat").args(["test/"]);
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(1));
}
CommandEvent::Stderr(line) => {
assert_eq!(
String::from_utf8(line).unwrap(),
"cat: test/: Is a directory\n"
);
}
_ => {}
}
}
});
}
#[cfg(not(windows))]
#[test]
// test the failure case (raw encoding)
fn test_cmd_spawn_raw_fail() {
let cmd = Command::new("cat").args(["test/"]);
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(1));
}
CommandEvent::Stderr(line) => {
assert_eq!(
String::from_utf8(line).unwrap(),
"cat: test/: Is a directory\n"
);
}
_ => {}
}
}
});
}
#[cfg(not(windows))]
#[test]
fn test_cmd_output_output() {
let cmd = Command::new("cat").args(["test/test.txt"]);
let output = tauri::async_runtime::block_on(cmd.output()).unwrap();
assert_eq!(String::from_utf8(output.stderr).unwrap(), "");
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
"This is a test doc!\n"
);
}
#[cfg(not(windows))]
#[test]
fn test_cmd_output_output_fail() {
let cmd = Command::new("cat").args(["test/"]);
let output = tauri::async_runtime::block_on(cmd.output()).unwrap();
assert_eq!(String::from_utf8(output.stdout).unwrap(), "");
assert_eq!(
String::from_utf8(output.stderr).unwrap(),
"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");
}
#[cfg(not(windows))]
fn wait_terminated(mut rx: Receiver<CommandEvent>) -> TerminatedPayload {
tauri::async_runtime::block_on(async move {
while let Some(event) = rx.recv().await {
if let CommandEvent::Terminated(payload) = event {
return payload;
}
}
panic!("channel closed without a Terminated event");
})
}
#[cfg(not(windows))]
#[test]
fn test_cmd_kill_on_drop() {
let (rx, child) = Command::new("sleep")
.args(["60"])
.kill_on_drop(true)
.spawn()
.unwrap();
drop(child);
let payload = wait_terminated(rx);
assert_eq!(payload.signal, Some(libc::SIGKILL));
}
#[cfg(not(windows))]
#[test]
fn test_cmd_no_kill_on_drop_by_default() {
let (_rx, child) = Command::new("sleep").args(["60"]).spawn().unwrap();
let pid = child.pid();
drop(child);
std::thread::sleep(std::time::Duration::from_millis(200));
let ret = unsafe { libc::kill(pid as i32, 0) };
assert_eq!(ret, 0, "process should still be running");
unsafe { libc::kill(pid as i32, libc::SIGKILL) };
}
#[cfg(not(windows))]
#[test]
fn test_cmd_kill_on_drop_process_group() {
// The shell's own child (sleep) must die too, not just the shell.
let (rx, child) = Command::new("sh")
.args(["-c", "sleep 60; true"])
.set_process_group(true)
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.pid();
drop(child);
wait_terminated(rx);
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/<script>.ps1` on Windows or `test/<script>.sh` elsewhere.
fn sim_command(script: &str) -> Command {
if cfg!(windows) {
Command::new("powershell").args([
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
format!("test/{script}.ps1"),
])
} else {
Command::new("sh").args([format!("test/{script}.sh")])
}
}
/// Reads command output until the wrapper script reports the grandchild pid.
fn read_grandchild_pid(rx: &mut Receiver<CommandEvent>) -> u32 {
tauri::async_runtime::block_on(async {
let mut pid = None;
while let Some(event) = rx.recv().await {
if let CommandEvent::Stdout(line) = &event {
let line_str = String::from_utf8_lossy(line);
if let Some(rest) = line_str.strip_prefix("CHILD_PID=") {
pid = rest.trim().parse::<u32>().ok();
}
}
if pid.is_some() {
break;
}
}
pid.expect("should have received CHILD_PID from script")
})
}
/// Asserts that the child produces a `Terminated` event.
fn wait_for_terminated(mut rx: Receiver<CommandEvent>) {
let got = tauri::async_runtime::block_on(async move {
while let Some(event) = rx.recv().await {
if let CommandEvent::Terminated(_) = event {
return true;
}
}
false
});
assert!(got, "expected a Terminated event");
}
#[cfg(not(windows))]
fn pid_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
fn pid_alive(pid: u32) -> bool {
use windows_sys::Win32::{
Foundation::{CloseHandle, STILL_ACTIVE},
System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
},
};
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return false;
}
let mut code = 0u32;
let alive =
unsafe { GetExitCodeProcess(handle, &mut code) } != 0 && code == STILL_ACTIVE as u32;
unsafe { CloseHandle(handle) };
alive
}
#[cfg(not(windows))]
fn force_kill(pid: u32) {
unsafe { libc::kill(pid as i32, libc::SIGKILL) };
}
#[cfg(windows)]
fn force_kill(pid: u32) {
use windows_sys::Win32::{
Foundation::CloseHandle,
System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess},
};
let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
if !handle.is_null() {
unsafe { TerminateProcess(handle, 1) };
unsafe { CloseHandle(handle) };
}
}
/// End-to-end test simulating the PyInstaller scenario from issue #1332.
///
/// PyInstaller wraps the real application in a thin bootloader process.
/// Without process groups, killing the bootloader orphans the real app.
/// This test verifies that with `process_group` enabled, killing the
/// wrapper also kills the grandchild process.
#[test]
fn test_pyinstaller_simulation_without_process_group() {
// Without process_group: killing the wrapper does NOT kill the grandchild.
let (mut rx, child) = sim_command("pyinstaller_sim").spawn().unwrap();
let grandchild_pid = read_grandchild_pid(&mut rx);
assert!(
pid_alive(grandchild_pid),
"grandchild should be running before kill"
);
// Kill just the direct child (no process group)
child.kill().unwrap();
std::thread::sleep(std::time::Duration::from_millis(100));
// The grandchild is STILL alive — this is the bug
assert!(
pid_alive(grandchild_pid),
"grandchild should survive when process_group is off"
);
// Clean up the orphaned grandchild. This also closes the inherited
// stdout pipe, which the Terminated event is gated on.
force_kill(grandchild_pid);
wait_for_terminated(rx);
}
#[test]
fn test_pyinstaller_simulation_with_process_group() {
// With process_group: killing the wrapper ALSO kills the grandchild.
let (mut rx, child) = sim_command("pyinstaller_sim")
.set_process_group(true)
.spawn()
.unwrap();
let grandchild_pid = read_grandchild_pid(&mut rx);
assert!(
pid_alive(grandchild_pid),
"grandchild should be running before kill"
);
// Kill the process group
child.kill().unwrap();
std::thread::sleep(std::time::Duration::from_millis(100));
// The grandchild should now be DEAD
assert!(
!pid_alive(grandchild_pid),
"grandchild should be killed when process_group is on"
);
wait_for_terminated(rx);
}
/// The direct child exits right after starting a grandchild (like a launcher).
/// Killing the process group or job object afterwards must still kill the grandchild.
#[test]
fn test_process_group_kill_after_leader_exit() {
let (mut rx, child) = sim_command("launcher_sim")
.set_process_group(true)
.spawn()
.unwrap();
let grandchild_pid = read_grandchild_pid(&mut rx);
// Wait until the launcher has exited and been reaped.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while child.inner.try_wait().unwrap().is_none() {
assert!(
std::time::Instant::now() < deadline,
"launcher should exit on its own"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(
pid_alive(grandchild_pid),
"grandchild should outlive the launcher"
);
child.kill().unwrap();
std::thread::sleep(std::time::Duration::from_millis(100));
let alive = pid_alive(grandchild_pid);
if alive {
force_kill(grandchild_pid);
}
assert!(
!alive,
"grandchild should be killed even though the launcher already exited"
);
}
}