Skip to content

Commit 883b045

Browse files
committed
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
1 parent 9d0d9ef commit 883b045

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

plugins/shell/src/process/mod.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,4 +879,89 @@ mod tests {
879879
"This is a test doc!\n"
880880
);
881881
}
882+
883+
/// End-to-end test simulating the PyInstaller scenario from issue #1332.
884+
///
885+
/// PyInstaller wraps the real application in a thin bootloader process.
886+
/// Without process groups, killing the bootloader orphans the real app.
887+
/// This test verifies that with `process_group` enabled, killing the
888+
/// wrapper also kills the grandchild process.
889+
#[cfg(not(windows))]
890+
#[test]
891+
fn test_pyinstaller_simulation_without_process_group() {
892+
// Without process_group: killing the wrapper does NOT kill the grandchild.
893+
let cmd = Command::new("sh").args(["test/pyinstaller_sim.sh"]);
894+
let (mut rx, child) = cmd.spawn().unwrap();
895+
896+
// Collect the child PID from stdout
897+
let grandchild_pid = tauri::async_runtime::block_on(async {
898+
let mut pid = None;
899+
while let Some(event) = rx.recv().await {
900+
if let CommandEvent::Stdout(line) = &event {
901+
let line_str = String::from_utf8_lossy(line);
902+
if let Some(rest) = line_str.strip_prefix("CHILD_PID=") {
903+
pid = rest.trim().parse::<i32>().ok();
904+
}
905+
}
906+
if pid.is_some() {
907+
break;
908+
}
909+
}
910+
pid.expect("should have received CHILD_PID from script")
911+
});
912+
913+
// Verify the grandchild is running
914+
let ret = unsafe { libc::kill(grandchild_pid, 0) };
915+
assert_eq!(ret, 0, "grandchild should be running before kill");
916+
917+
// Kill just the direct child (no process group)
918+
child.kill().unwrap();
919+
std::thread::sleep(std::time::Duration::from_millis(100));
920+
921+
// The grandchild is STILL alive — this is the bug
922+
let ret = unsafe { libc::kill(grandchild_pid, 0) };
923+
assert_eq!(ret, 0, "grandchild should survive when process_group is off");
924+
925+
// Clean up the orphaned grandchild
926+
unsafe { libc::kill(grandchild_pid, libc::SIGKILL) };
927+
}
928+
929+
#[cfg(not(windows))]
930+
#[test]
931+
fn test_pyinstaller_simulation_with_process_group() {
932+
// With process_group: killing the wrapper ALSO kills the grandchild.
933+
let cmd = Command::new("sh")
934+
.args(["test/pyinstaller_sim.sh"])
935+
.set_process_group(true);
936+
let (mut rx, child) = cmd.spawn().unwrap();
937+
938+
// Collect the grandchild PID from stdout
939+
let grandchild_pid = tauri::async_runtime::block_on(async {
940+
let mut pid = None;
941+
while let Some(event) = rx.recv().await {
942+
if let CommandEvent::Stdout(line) = &event {
943+
let line_str = String::from_utf8_lossy(line);
944+
if let Some(rest) = line_str.strip_prefix("CHILD_PID=") {
945+
pid = rest.trim().parse::<i32>().ok();
946+
}
947+
}
948+
if pid.is_some() {
949+
break;
950+
}
951+
}
952+
pid.expect("should have received CHILD_PID from script")
953+
});
954+
955+
// Verify the grandchild is running
956+
let ret = unsafe { libc::kill(grandchild_pid, 0) };
957+
assert_eq!(ret, 0, "grandchild should be running before kill");
958+
959+
// Kill the process group
960+
child.kill().unwrap();
961+
std::thread::sleep(std::time::Duration::from_millis(100));
962+
963+
// The grandchild should now be DEAD
964+
let ret = unsafe { libc::kill(grandchild_pid, 0) };
965+
assert_ne!(ret, 0, "grandchild should be killed when process_group is on");
966+
}
882967
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/bash
2+
# Simulates a PyInstaller-wrapped application.
3+
#
4+
# PyInstaller bundles a thin "bootloader" that spawns the real Python app
5+
# as a child process. When Tauri kills the bootloader, the real app is
6+
# orphaned unless the entire process group is terminated.
7+
#
8+
# This script mimics that pattern:
9+
# - It spawns a long-running child ("the real app")
10+
# - Prints the child's PID so the test harness can verify it was killed
11+
# - Waits on the child (like PyInstaller's bootloader does)
12+
13+
# "The real application" — a grandchild from Tauri's perspective
14+
sleep 3600 &
15+
CHILD_PID=$!
16+
17+
echo "WRAPPER_PID=$$"
18+
echo "CHILD_PID=$CHILD_PID"
19+
20+
# The bootloader waits for the real app to finish
21+
wait $CHILD_PID

0 commit comments

Comments
 (0)