feat(shell): add process group spawn option (fix #1332) (#3351)

* feat(shell): add process group spawn option (fix #1332)

Add a `processGroup` boolean option to the shell plugin's spawn command.
When enabled, the child process is spawned in its own process group (POSIX)
or job object (Windows) using the `process-wrap` crate, so that killing
the child also kills the entire process tree.

This fixes the issue where programs like PyInstaller wrappers spawn a
child process that gets orphaned when Tauri kills the parent.

* test(shell): add PyInstaller simulation tests for process group kill

Add end-to-end tests that reproduce the exact scenario from issue #1332:
a wrapper process (like PyInstaller's bootloader) spawns a grandchild.

- Without process_group: killing the wrapper orphans the grandchild
- With process_group: killing the wrapper also kills the grandchild

* refactor(shell): drop redundant cfg gates, trim process-wrap features

Address review feedback on #3351:

- Remove `#[cfg(any(unix, windows))]` gates on `ChildKind::ProcessGroup`,
  the kill/pid match arms, and the spawn block, plus the now-dead
  `#[cfg(not(any(unix, windows)))]` fallback. That cfg only excluded wasm,
  which this plugin never builds for.
- Set `default-features = false` on process-wrap and enable only the
  features actually used: std, process-group (unix), creation-flags and
  job-object (windows). Drops kill-on-drop, process-session, and tracing.

* fix(shell): enable process-wrap tracing feature, fix CI formatting and changefile

- Add `tracing` feature to process-wrap: with default-features = false its
  Windows job_object.rs calls `debug\!` unconditionally while the import is
  gated behind `#[cfg(feature = "tracing")]`, failing the Windows build with
  "cannot find macro `debug`".
- Reformat process/mod.rs assertions to satisfy `cargo fmt --check`.
- Reindent the process-wrap features array to 2 spaces for taplo.
- Use covector package keys (`shell` / `shell-js`) in the change file so the
  check-change-files and covector status jobs pass.

* refactor(shell): unify child handling on process-wrap, drop SharedChild

Collapse the ChildKind enum and the per-platform GroupChild types into a
single process-wrap-backed child. The command always spawns through
StdCommandWrap; the process_group flag is now just an optional wrapper
rather than a switch into a separate child type.

This drops the shared_child dependency and resolves two review notes:
the Unix path no longer hand-rolls killpg, and kill() now goes through
process-wrap's kill (start_kill + wait), which reaps the entire process
group instead of only signalling it.

A background thread polls try_wait to surface the exit status, since
process-wrap's wrappers only expose &mut self wait methods (the reason
SharedChild was used on Unix in the first place).

* chore(example): add process group toggle to shell view

* fix(shell): pass typed creation flags to process-wrap on Windows

`process_wrap::std::CreationFlags` is a newtype over the `windows` crate's
`PROCESS_CREATION_FLAGS`, not a raw `u32`, so the Windows build failed to
compile. Depend on `windows` (already in the tree via `tauri`, pinned to the
same 0.61 that `process-wrap` links against) and use its `CREATE_NO_WINDOW`
constant, which drops the hand-rolled magic number.

The wrap is still required: `JobObject::pre_spawn` calls `creation_flags` and
would otherwise clobber the `CREATE_NO_WINDOW` set at construction.

* perf(shell): block on child exit instead of polling try_wait

The switch from `SharedChild` to `process-wrap` moved the child behind a
mutex, since `StdChildWrapper`'s wait methods take `&mut self` while both
the wait thread and `CommandChild::kill()` need access. A blocking wait
would hold the lock for the child's whole lifetime and starve `kill()`,
so the wait thread polled `try_wait` every 10ms instead.

Block on the raw process outside the lock using a wait that does not reap
(`waitid` with `WNOWAIT` on unix, `WaitForSingleObject` on windows), then
take the lock once to let process-wrap collect the exit status. This is
the same technique shared_child uses internally, applied on top of
process-wrap's kill/reap semantics. No new dependencies or Cargo features
are required.

`kill()` reaps the child while holding the lock, so the raw wait can lose
that race and return ECHILD. That means the exit status is already cached
in the wrapper, so it is treated as success and the status is collected
via `try_wait`. Without this guard, a stress test of 300 spawn+kill
cycles produced 36 Error events in place of Terminated; with it, none in
900 cycles. The `try_wait` loop stays as a defensive fallback, though in
practice the first call succeeds immediately.

Mean latency for `Command::output()` on macOS (debug, 30 iterations of
`echo`) drops from 13.22ms to ~1.8ms, and 10 idle 5s children cost ~11ms
CPU total instead of ~75ms with the poll loop.

* fix(shell): keep CREATE_NO_WINDOW on process-group children (windows)

process-wrap's CreationFlags/JobObject coordination is broken during
spawn (watchexec/process-wrap#35): JobObject::pre_spawn cannot see the
CreationFlags wrapper, so it overwrote our flags with just
CREATE_SUSPENDED and process-group children flashed a console window.

Register CreationFlags(CREATE_SUSPENDED | CREATE_NO_WINDOW) after
JobObject so its pre_spawn runs last and wins. CREATE_SUSPENDED is kept
so the child cannot spawn grandchildren before job assignment; the same
upstream bug keeps wrap_child's resume check from seeing it, so the
child is still resumed.

* test(shell): run the process-tree kill tests on windows

Add a powershell variant of the pyinstaller simulation script and lift
the unix-only gates on the process-group tests, so the windows job in
test-rust exercises the job-object spawn/kill path instead of skipping
it. Both pyinstaller tests now also assert that a Terminated event is
still delivered after kill().

* Update plugins/shell/src/process/mod.rs

Co-authored-by: Fabian-Lars <30730186+FabianLars@users.noreply.github.com>

* Update .changes/shell-process-group.md

Co-authored-by: Fabian-Lars <30730186+FabianLars@users.noreply.github.com>

* Update plugins/shell/src/process/mod.rs

Co-authored-by: Fabian-Lars <30730186+FabianLars@users.noreply.github.com>

* Delete .changes/shell-wait-no-poll.md

* refactor(shell): replace process-wrap with shared_child and an inlined job object

Use std process groups on unix and a job object on windows, with
shared_child handling wait and kill on all platforms.

* fix(shell): kill the process group even after its leader exited

CommandChild::kill returned early once the direct child had been reaped,
so killpg/TerminateJobObject never ran and anything the child left running
in its process group or job object survived, including on app exit.

Adds a launcher-style test (the child starts a grandchild and exits) that
runs on Windows, Linux and macOS.

* fix(shell): restore Debug on CommandChild

Dropping the derive was a breaking change for downstream types that derive
Debug and hold a CommandChild.

* fix(shell): don't block the main thread in the kill command

CommandChild::kill now waits for the child to exit, and the sync kill
command ran it on the main thread while still holding the children lock.
Make the command async and release the lock before killing.

---------

Co-authored-by: Fabian-Lars <30730186+FabianLars@users.noreply.github.com>
Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
authored and GitHub committed 2026-10-09 15:08:15 -03:00
1 parent e03dca60e7
commit a8acd178bd
12 files changed
+563 -12

No files matched your search

+6
View File
@@ -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.
Generated
+5 -3
View File
@@ -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]]
+13
View File
@@ -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"
/>
</div>
<div class="flex items-center gap-1">
<input
type="checkbox"
id="shell-process-group"
bind:checked={processGroup}
/>
<label for="shell-process-group">Process group</label>
</div>
<div class="flex children:grow gap-1">
<button class="btn" on:click={spawn}>Run</button>
<button class="btn" on:click={kill}>Kill</button>
+13 -1
View File
@@ -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"] }
+10
View File
@@ -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 */
+12 -2
View File
@@ -88,6 +88,10 @@ pub struct CommandOptions {
env: Option<HashMap<String, String>>,
// Character encoding for stdout/stderr
encoding: Option<String>,
// 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<R: Runtime>(
} 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<R: Runtime>(
Ok(())
}
// Async so waiting for the child to exit doesn't block the main thread.
#[tauri::command]
pub fn kill<R: Runtime>(
pub async fn kill<R: Runtime>(
_window: Window<R>,
shell: State<'_, Shell<R>>,
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(())
+90
View File
@@ -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<Self> {
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::<THREADENTRY32>() 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(())
}
+357 -6
View File
@@ -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<SharedChild>,
stdin_writer: PipeWriter,
#[cfg(unix)]
process_group: bool,
#[cfg(windows)]
job: Option<job_object::JobObject>,
}
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<CommandEvent>, 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/<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, TerminateProcess, PROCESS_TERMINATE},
};
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"
);
}
}
+7
View File
@@ -0,0 +1,7 @@
# Simulates a launcher that starts the real application and exits right away,
# leaving the application running in its job object.
$child = Start-Process -PassThru -WindowStyle Hidden powershell.exe -ArgumentList '-NoProfile', '-Command', 'Start-Sleep -Seconds 3600'
"WRAPPER_PID=$PID"
"CHILD_PID=$($child.Id)"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Simulates a launcher that starts the real application and exits right away,
# leaving the application running in its process group.
#
# The grandchild's output is redirected so it doesn't hold the test's pipes open.
sleep 3600 >/dev/null 2>&1 &
echo "WRAPPER_PID=$$"
echo "CHILD_PID=$!"
+19
View File
@@ -0,0 +1,19 @@
# Simulates a PyInstaller-wrapped application.
#
# PyInstaller bundles a thin "bootloader" that spawns the real Python app
# as a child process. When Tauri kills the bootloader, the real app is
# orphaned unless the entire job object is terminated.
#
# This script mimics that pattern:
# - It spawns a long-running child ("the real app")
# - Prints the child's PID so the test harness can verify it was killed
# - Waits on the child (like PyInstaller's bootloader does)
# "The real application" — a grandchild from Tauri's perspective
$child = Start-Process -PassThru -WindowStyle Hidden powershell.exe -ArgumentList '-NoProfile', '-Command', 'Start-Sleep -Seconds 3600'
"WRAPPER_PID=$PID"
"CHILD_PID=$($child.Id)"
# The bootloader waits for the real app to finish
Wait-Process -Id $child.Id
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Simulates a PyInstaller-wrapped application.
#
# PyInstaller bundles a thin "bootloader" that spawns the real Python app
# as a child process. When Tauri kills the bootloader, the real app is
# orphaned unless the entire process group is terminated.
#
# This script mimics that pattern:
# - It spawns a long-running child ("the real app")
# - Prints the child's PID so the test harness can verify it was killed
# - Waits on the child (like PyInstaller's bootloader does)
# "The real application" — a grandchild from Tauri's perspective
sleep 3600 &
CHILD_PID=$!
echo "WRAPPER_PID=$$"
echo "CHILD_PID=$CHILD_PID"
# The bootloader waits for the real app to finish
wait $CHILD_PID