Skip to main content

tauri_plugin_shell/process/
mod.rs

1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use std::{
6    ffi::OsStr,
7    io::{BufRead, BufReader, Write},
8    path::{Path, PathBuf},
9    process::{Command as StdCommand, Stdio},
10    sync::Arc,
11    thread::{JoinHandle, spawn},
12};
13
14#[cfg(unix)]
15use std::os::unix::process::{CommandExt, ExitStatusExt};
16#[cfg(windows)]
17use std::os::windows::process::CommandExt;
18
19#[cfg(windows)]
20use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
21
22const NEWLINE_BYTE: u8 = b'\n';
23
24use tauri::async_runtime::{Receiver, Sender, block_on as block_on_task, channel};
25
26pub use encoding_rs::Encoding;
27use os_pipe::{PipeReader, PipeWriter, pipe};
28use serde::Serialize;
29use shared_child::SharedChild;
30use tauri::utils::platform;
31
32#[cfg(windows)]
33mod job_object;
34
35/// Payload for the [`CommandEvent::Terminated`] command event.
36#[derive(Debug, Clone, Serialize)]
37pub struct TerminatedPayload {
38    /// Exit code of the process.
39    pub code: Option<i32>,
40    /// If the process was terminated by a signal, represents that signal.
41    pub signal: Option<i32>,
42}
43
44/// A event sent to the command callback.
45#[derive(Debug, Clone)]
46#[non_exhaustive]
47pub enum CommandEvent {
48    /// If configured for raw output, all bytes written to stderr.
49    /// Otherwise, bytes until a newline (\n) or carriage return (\r) is found.
50    Stderr(Vec<u8>),
51    /// If configured for raw output, all bytes written to stdout.
52    /// Otherwise, bytes until a newline (\n) or carriage return (\r) is found.
53    Stdout(Vec<u8>),
54    /// An error happened waiting for the command to finish or converting the stdout/stderr bytes to a UTF-8 string.
55    Error(String),
56    /// Command process terminated.
57    Terminated(TerminatedPayload),
58}
59
60/// The type to spawn commands.
61#[derive(Debug)]
62pub struct Command {
63    cmd: StdCommand,
64    raw_out: bool,
65    process_group: bool,
66}
67
68/// Spawned child process.
69#[derive(Debug)]
70pub struct CommandChild {
71    inner: Arc<SharedChild>,
72    stdin_writer: PipeWriter,
73    #[cfg(unix)]
74    process_group: bool,
75    #[cfg(windows)]
76    job: Option<job_object::JobObject>,
77}
78
79impl CommandChild {
80    /// Writes to process stdin.
81    pub fn write(&mut self, buf: &[u8]) -> crate::Result<()> {
82        self.stdin_writer.write_all(buf)?;
83        Ok(())
84    }
85
86    /// Sends a kill signal to the child and waits for it to exit.
87    /// With `process_group` enabled this kills the whole process group (POSIX) or job object (Windows).
88    pub fn kill(self) -> crate::Result<()> {
89        // No early return when the child already exited: the rest of its
90        // process group or job object may still be running.
91        #[cfg(unix)]
92        if self.process_group {
93            // The pgid can't be reused while the group has members, so this is
94            // safe even after the group leader was reaped.
95            let pgid = self.inner.id() as libc::pid_t;
96            if unsafe { libc::killpg(pgid, libc::SIGKILL) } != 0 {
97                let err = std::io::Error::last_os_error();
98                // ESRCH: every process in the group already exited.
99                if err.raw_os_error() != Some(libc::ESRCH) {
100                    return Err(err.into());
101                }
102            }
103        } else {
104            self.inner.kill()?;
105        }
106
107        #[cfg(windows)]
108        match &self.job {
109            Some(job) => job.terminate()?,
110            None => self.inner.kill()?,
111        }
112
113        self.inner.wait()?;
114        Ok(())
115    }
116
117    /// Returns the process pid.
118    pub fn pid(&self) -> u32 {
119        self.inner.id()
120    }
121}
122
123/// Describes the result of a process after it has terminated.
124#[derive(Debug)]
125pub struct ExitStatus {
126    // This field is intentionally left private.
127    // See: https://github.com/tauri-apps/plugins-workspace/pull/3115.
128    code: Option<i32>,
129}
130
131impl ExitStatus {
132    /// Returns the exit code of the process, if any.
133    pub fn code(&self) -> Option<i32> {
134        self.code
135    }
136
137    /// Returns true if exit status is zero. Signal termination is not considered a success, and success is defined as a zero exit status.
138    pub fn success(&self) -> bool {
139        self.code == Some(0)
140    }
141}
142
143/// The output of a finished process.
144#[derive(Debug)]
145pub struct Output {
146    /// The status (exit code) of the process.
147    pub status: ExitStatus,
148    /// The data that the process wrote to stdout.
149    pub stdout: Vec<u8>,
150    /// The data that the process wrote to stderr.
151    pub stderr: Vec<u8>,
152}
153
154fn relative_command_path(command: &Path) -> crate::Result<PathBuf> {
155    let exe_path = platform::current_exe()?;
156
157    let exe_dir = exe_path
158        .parent()
159        .ok_or(crate::Error::CurrentExeHasNoParent)?;
160
161    // If a test is being run, the executable is in the "deps" directory, so we need to go up one level.
162    let base_dir = if exe_dir.ends_with("deps") {
163        exe_dir.parent().unwrap_or(exe_dir)
164    } else {
165        exe_dir
166    };
167
168    let mut command_path = base_dir.join(command);
169
170    #[cfg(windows)]
171    {
172        let already_exe = command_path.extension().is_some_and(|ext| ext == "exe");
173        if !already_exe {
174            // do not use with_extension to retain dots in the command filename
175            command_path.as_mut_os_string().push(".exe");
176        }
177    }
178
179    #[cfg(not(windows))]
180    {
181        if command_path.extension().is_some_and(|ext| ext == "exe") {
182            command_path.set_extension("");
183        }
184    }
185
186    Ok(command_path)
187}
188
189impl From<Command> for StdCommand {
190    fn from(cmd: Command) -> StdCommand {
191        cmd.cmd
192    }
193}
194
195impl Command {
196    pub(crate) fn new<S: AsRef<OsStr>>(program: S) -> Self {
197        log::debug!(
198            "Creating sidecar {}",
199            program.as_ref().to_str().unwrap_or("")
200        );
201        let mut command = StdCommand::new(program);
202
203        command.stdout(Stdio::piped());
204        command.stdin(Stdio::piped());
205        command.stderr(Stdio::piped());
206        #[cfg(windows)]
207        command.creation_flags(CREATE_NO_WINDOW);
208
209        Self {
210            cmd: command,
211            raw_out: false,
212            process_group: false,
213        }
214    }
215
216    pub(crate) fn new_sidecar<S: AsRef<Path>>(program: S) -> crate::Result<Self> {
217        Ok(Self::new(relative_command_path(program.as_ref())?))
218    }
219
220    /// Appends an argument to the command.
221    #[must_use]
222    pub fn arg<S: AsRef<OsStr>>(mut self, arg: S) -> Self {
223        self.cmd.arg(arg);
224        self
225    }
226
227    /// Appends arguments to the command.
228    #[must_use]
229    pub fn args<I, S>(mut self, args: I) -> Self
230    where
231        I: IntoIterator<Item = S>,
232        S: AsRef<OsStr>,
233    {
234        self.cmd.args(args);
235        self
236    }
237
238    /// Clears the entire environment map for the child process.
239    #[must_use]
240    pub fn env_clear(mut self) -> Self {
241        self.cmd.env_clear();
242        self
243    }
244
245    /// Inserts or updates an explicit environment variable mapping.
246    #[must_use]
247    pub fn env<K, V>(mut self, key: K, value: V) -> Self
248    where
249        K: AsRef<OsStr>,
250        V: AsRef<OsStr>,
251    {
252        self.cmd.env(key, value);
253        self
254    }
255
256    /// Adds or updates multiple environment variable mappings.
257    #[must_use]
258    pub fn envs<I, K, V>(mut self, envs: I) -> Self
259    where
260        I: IntoIterator<Item = (K, V)>,
261        K: AsRef<OsStr>,
262        V: AsRef<OsStr>,
263    {
264        self.cmd.envs(envs);
265        self
266    }
267
268    /// Sets the working directory for the child process.
269    #[must_use]
270    pub fn current_dir<P: AsRef<Path>>(mut self, current_dir: P) -> Self {
271        self.cmd.current_dir(current_dir);
272        self
273    }
274
275    /// Configures the reader to output bytes from the child process exactly as received
276    pub fn set_raw_out(mut self, raw_out: bool) -> Self {
277        self.raw_out = raw_out;
278        self
279    }
280
281    /// Configures the command to spawn in a new process group (POSIX) or job object (Windows).
282    ///
283    /// When enabled, killing the child process will also kill all processes in the group,
284    /// which is useful for programs that spawn child processes (e.g. PyInstaller wrappers).
285    #[must_use]
286    pub fn set_process_group(mut self, process_group: bool) -> Self {
287        self.process_group = process_group;
288        self
289    }
290
291    /// Spawns the command.
292    ///
293    /// # Examples
294    ///
295    /// ```rust,no_run
296    /// use tauri_plugin_shell::{process::CommandEvent, ShellExt};
297    /// tauri::Builder::default()
298    ///   .setup(|app| {
299    ///     let handle = app.handle().clone();
300    ///     tauri::async_runtime::spawn(async move {
301    ///       let (mut rx, mut child) = handle
302    ///         .shell()
303    ///         .command("cargo")
304    ///         .args(["tauri", "dev"])
305    ///         .spawn()
306    ///         .expect("Failed to spawn cargo");
307    ///
308    ///       let mut i = 0;
309    ///       while let Some(event) = rx.recv().await {
310    ///         if let CommandEvent::Stdout(line) = event {
311    ///           println!("got: {}", String::from_utf8(line).unwrap());
312    ///           i += 1;
313    ///           if i == 4 {
314    ///             child.write("message from Rust\n".as_bytes()).unwrap();
315    ///             i = 0;
316    ///           }
317    ///         }
318    ///       }
319    ///     });
320    ///     Ok(())
321    ///   });
322    /// ```
323    ///
324    /// Depending on the command you spawn, it might output in a specific encoding, to parse the output lines in this case:
325    ///
326    /// ```rust,no_run
327    /// use tauri_plugin_shell::{process::{CommandEvent, Encoding}, ShellExt};
328    /// tauri::Builder::default()
329    ///   .setup(|app| {
330    ///     let handle = app.handle().clone();
331    ///     tauri::async_runtime::spawn(async move {
332    ///       let (mut rx, mut child) = handle
333    ///         .shell()
334    ///         .command("some-program")
335    ///         .arg("some-arg")
336    ///         .spawn()
337    ///         .expect("Failed to spawn some-program");
338    ///
339    ///       let encoding = Encoding::for_label(b"windows-1252").unwrap();
340    ///       while let Some(event) = rx.recv().await {
341    ///         if let CommandEvent::Stdout(line) = event {
342    ///           let (decoded, _, _) = encoding.decode(&line);
343    ///           println!("got: {decoded}");
344    ///         }
345    ///       }
346    ///     });
347    ///     Ok(())
348    ///   });
349    /// ```
350    pub fn spawn(self) -> crate::Result<(Receiver<CommandEvent>, CommandChild)> {
351        let raw = self.raw_out;
352        let process_group = self.process_group;
353        let mut command: StdCommand = self.into();
354        let (stdout_reader, stdout_writer) = pipe()?;
355        let (stderr_reader, stderr_writer) = pipe()?;
356        let (stdin_reader, stdin_writer) = pipe()?;
357        command.stdout(stdout_writer);
358        command.stderr(stderr_writer);
359        command.stdin(stdin_reader);
360
361        #[cfg(unix)]
362        if process_group {
363            command.process_group(0);
364        }
365        #[cfg(windows)]
366        if process_group {
367            // Start suspended so nothing can be spawned before the child is in the job.
368            command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
369        }
370
371        let child = command.spawn()?;
372
373        #[cfg(windows)]
374        let job = if process_group {
375            match job_object::JobObject::assign(&child) {
376                Ok(job) => Some(job),
377                Err(e) => {
378                    // Don't leave the suspended child behind.
379                    let mut child = child;
380                    let _ = child.kill();
381                    let _ = child.wait();
382                    return Err(e.into());
383                }
384            }
385        } else {
386            None
387        };
388
389        let child = Arc::new(SharedChild::new(child)?);
390        let child_ = child.clone();
391        let (tx, rx) = channel(1);
392
393        let stdout_thread = spawn_pipe_reader(tx.clone(), stdout_reader, CommandEvent::Stdout, raw);
394        let stderr_thread = spawn_pipe_reader(tx.clone(), stderr_reader, CommandEvent::Stderr, raw);
395
396        spawn(move || {
397            let status = child_.wait();
398            // `Terminated` must be the last event: wait for the readers to drain the pipes,
399            // since a short-lived child can exit before they have read anything.
400            let _ = stdout_thread.join();
401            let _ = stderr_thread.join();
402            let _ = match status {
403                Ok(status) => block_on_task(async move {
404                    tx.send(CommandEvent::Terminated(TerminatedPayload {
405                        code: status.code(),
406                        #[cfg(windows)]
407                        signal: None,
408                        #[cfg(unix)]
409                        signal: status.signal(),
410                    }))
411                    .await
412                }),
413                Err(e) => {
414                    block_on_task(async move { tx.send(CommandEvent::Error(e.to_string())).await })
415                }
416            };
417        });
418
419        Ok((
420            rx,
421            CommandChild {
422                inner: child,
423                stdin_writer,
424                #[cfg(unix)]
425                process_group,
426                #[cfg(windows)]
427                job,
428            },
429        ))
430    }
431
432    /// Executes a command as a child process, waiting for it to finish and collecting its exit status.
433    /// Stdin, stdout and stderr are ignored.
434    ///
435    /// # Examples
436    /// ```rust,no_run
437    /// use tauri_plugin_shell::ShellExt;
438    /// tauri::Builder::default()
439    ///   .setup(|app| {
440    ///     let status = tauri::async_runtime::block_on(async move { app.shell().command("which").args(["ls"]).status().await.unwrap() });
441    ///     println!("`which` finished with status: {:?}", status.code());
442    ///     Ok(())
443    ///   });
444    /// ```
445    pub async fn status(self) -> crate::Result<ExitStatus> {
446        let (mut rx, _child) = self.spawn()?;
447        let mut code = None;
448        #[allow(clippy::collapsible_match)]
449        while let Some(event) = rx.recv().await {
450            if let CommandEvent::Terminated(payload) = event {
451                code = payload.code;
452            }
453        }
454        Ok(ExitStatus { code })
455    }
456
457    /// Executes the command as a child process, waiting for it to finish and collecting all of its output.
458    /// Stdin is ignored.
459    ///
460    /// # Examples
461    ///
462    /// ```rust,no_run
463    /// use tauri_plugin_shell::ShellExt;
464    /// tauri::Builder::default()
465    ///   .setup(|app| {
466    ///     let output = tauri::async_runtime::block_on(async move { app.shell().command("echo").args(["TAURI"]).output().await.unwrap() });
467    ///     assert!(output.status.success());
468    ///     assert_eq!(String::from_utf8(output.stdout).unwrap(), "TAURI");
469    ///     Ok(())
470    ///   });
471    /// ```
472    pub async fn output(self) -> crate::Result<Output> {
473        let (mut rx, _child) = self.spawn()?;
474
475        let mut code = None;
476        let mut stdout = Vec::new();
477        let mut stderr = Vec::new();
478
479        while let Some(event) = rx.recv().await {
480            match event {
481                CommandEvent::Terminated(payload) => {
482                    code = payload.code;
483                }
484                CommandEvent::Stdout(line) => {
485                    stdout.extend(line);
486                    stdout.push(NEWLINE_BYTE);
487                }
488                CommandEvent::Stderr(line) => {
489                    stderr.extend(line);
490                    stderr.push(NEWLINE_BYTE);
491                }
492                CommandEvent::Error(_) => {}
493            }
494        }
495        Ok(Output {
496            status: ExitStatus { code },
497            stdout,
498            stderr,
499        })
500    }
501}
502
503fn read_raw_bytes<F: Fn(Vec<u8>) -> CommandEvent + Send + Copy + 'static>(
504    mut reader: BufReader<PipeReader>,
505    tx: Sender<CommandEvent>,
506    wrapper: F,
507) {
508    loop {
509        let result = reader.fill_buf();
510        match result {
511            Ok(buf) => {
512                let length = buf.len();
513                if length == 0 {
514                    break;
515                }
516                let tx_ = tx.clone();
517                let _ = block_on_task(async move { tx_.send(wrapper(buf.to_vec())).await });
518                reader.consume(length);
519            }
520            Err(e) => {
521                let tx_ = tx.clone();
522                let _ = block_on_task(
523                    async move { tx_.send(CommandEvent::Error(e.to_string())).await },
524                );
525            }
526        }
527    }
528}
529
530fn read_line<F: Fn(Vec<u8>) -> CommandEvent + Send + Copy + 'static>(
531    mut reader: BufReader<PipeReader>,
532    tx: Sender<CommandEvent>,
533    wrapper: F,
534) {
535    loop {
536        let mut buf = Vec::new();
537        match tauri::utils::io::read_line(&mut reader, &mut buf) {
538            Ok(n) => {
539                if n == 0 {
540                    break;
541                }
542                let tx_ = tx.clone();
543                let _ = block_on_task(async move { tx_.send(wrapper(buf)).await });
544            }
545            Err(e) => {
546                let _ =
547                    block_on_task(async move { tx.send(CommandEvent::Error(e.to_string())).await });
548                break;
549            }
550        }
551    }
552}
553
554fn spawn_pipe_reader<F: Fn(Vec<u8>) -> CommandEvent + Send + Copy + 'static>(
555    tx: Sender<CommandEvent>,
556    pipe_reader: PipeReader,
557    wrapper: F,
558    raw_out: bool,
559) -> JoinHandle<()> {
560    spawn(move || {
561        let reader = BufReader::new(pipe_reader);
562
563        if raw_out {
564            read_raw_bytes(reader, tx, wrapper);
565        } else {
566            read_line(reader, tx, wrapper);
567        }
568    })
569}
570
571// tests for the commands functions.
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn relative_command_path_resolves() {
578        let cwd_parent = platform::current_exe()
579            .unwrap()
580            .parent()
581            .unwrap()
582            .parent() // Go up once more to get out of the "deps" directory
583            .unwrap()
584            .to_owned();
585        assert_eq!(
586            relative_command_path(Path::new("Tauri.Example")).unwrap(),
587            cwd_parent.join(if cfg!(windows) {
588                "Tauri.Example.exe"
589            } else {
590                "Tauri.Example"
591            })
592        );
593        assert_eq!(
594            relative_command_path(Path::new("Tauri.Example.exe")).unwrap(),
595            cwd_parent.join(if cfg!(windows) {
596                "Tauri.Example.exe"
597            } else {
598                "Tauri.Example"
599            })
600        );
601    }
602
603    #[cfg(not(windows))]
604    #[test]
605    fn test_cmd_spawn_output() {
606        let cmd = Command::new("cat").args(["test/test.txt"]);
607        let (mut rx, _) = cmd.spawn().unwrap();
608
609        tauri::async_runtime::block_on(async move {
610            while let Some(event) = rx.recv().await {
611                match event {
612                    CommandEvent::Terminated(payload) => {
613                        assert_eq!(payload.code, Some(0));
614                    }
615                    CommandEvent::Stdout(line) => {
616                        assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!");
617                    }
618                    _ => {}
619                }
620            }
621        });
622    }
623
624    #[cfg(not(windows))]
625    #[test]
626    fn test_cmd_spawn_raw_output() {
627        let cmd = Command::new("cat").args(["test/test.txt"]);
628        let (mut rx, _) = cmd.spawn().unwrap();
629
630        tauri::async_runtime::block_on(async move {
631            while let Some(event) = rx.recv().await {
632                match event {
633                    CommandEvent::Terminated(payload) => {
634                        assert_eq!(payload.code, Some(0));
635                    }
636                    CommandEvent::Stdout(line) => {
637                        assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!");
638                    }
639                    _ => {}
640                }
641            }
642        });
643    }
644
645    #[cfg(not(windows))]
646    #[test]
647    // test the failure case
648    fn test_cmd_spawn_fail() {
649        let cmd = Command::new("cat").args(["test/"]);
650        let (mut rx, _) = cmd.spawn().unwrap();
651
652        tauri::async_runtime::block_on(async move {
653            while let Some(event) = rx.recv().await {
654                match event {
655                    CommandEvent::Terminated(payload) => {
656                        assert_eq!(payload.code, Some(1));
657                    }
658                    CommandEvent::Stderr(line) => {
659                        assert_eq!(
660                            String::from_utf8(line).unwrap(),
661                            "cat: test/: Is a directory\n"
662                        );
663                    }
664                    _ => {}
665                }
666            }
667        });
668    }
669
670    #[cfg(not(windows))]
671    #[test]
672    // test the failure case (raw encoding)
673    fn test_cmd_spawn_raw_fail() {
674        let cmd = Command::new("cat").args(["test/"]);
675        let (mut rx, _) = cmd.spawn().unwrap();
676
677        tauri::async_runtime::block_on(async move {
678            while let Some(event) = rx.recv().await {
679                match event {
680                    CommandEvent::Terminated(payload) => {
681                        assert_eq!(payload.code, Some(1));
682                    }
683                    CommandEvent::Stderr(line) => {
684                        assert_eq!(
685                            String::from_utf8(line).unwrap(),
686                            "cat: test/: Is a directory\n"
687                        );
688                    }
689                    _ => {}
690                }
691            }
692        });
693    }
694
695    #[cfg(not(windows))]
696    #[test]
697    fn test_cmd_output_output() {
698        let cmd = Command::new("cat").args(["test/test.txt"]);
699        let output = tauri::async_runtime::block_on(cmd.output()).unwrap();
700
701        assert_eq!(String::from_utf8(output.stderr).unwrap(), "");
702        assert_eq!(
703            String::from_utf8(output.stdout).unwrap(),
704            "This is a test doc!\n"
705        );
706    }
707
708    #[cfg(not(windows))]
709    #[test]
710    fn test_cmd_output_output_fail() {
711        let cmd = Command::new("cat").args(["test/"]);
712        let output = tauri::async_runtime::block_on(cmd.output()).unwrap();
713
714        assert_eq!(String::from_utf8(output.stdout).unwrap(), "");
715        assert_eq!(
716            String::from_utf8(output.stderr).unwrap(),
717            "cat: test/: Is a directory\n\n"
718        );
719    }
720
721    #[cfg(not(windows))]
722    #[test]
723    fn test_cmd_spawn_process_group_output() {
724        let cmd = Command::new("cat")
725            .args(["test/test.txt"])
726            .set_process_group(true);
727        let (mut rx, _) = cmd.spawn().unwrap();
728
729        tauri::async_runtime::block_on(async move {
730            while let Some(event) = rx.recv().await {
731                match event {
732                    CommandEvent::Terminated(payload) => {
733                        assert_eq!(payload.code, Some(0));
734                    }
735                    CommandEvent::Stdout(line) => {
736                        assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!");
737                    }
738                    _ => {}
739                }
740            }
741        });
742    }
743
744    #[cfg(not(windows))]
745    #[test]
746    fn test_cmd_process_group_kill() {
747        // Spawn a shell that runs a sleep command as a child process.
748        // With process_group enabled, killing the parent should also kill the child.
749        let cmd = Command::new("sh")
750            .args(["-c", "sleep 60"])
751            .set_process_group(true);
752        let (mut rx, child) = cmd.spawn().unwrap();
753        let pid = child.pid();
754
755        // Verify the process is running
756        let ret = unsafe { libc::kill(pid as i32, 0) };
757        assert_eq!(ret, 0, "process should be running");
758
759        // Kill the process group
760        child.kill().unwrap();
761
762        tauri::async_runtime::block_on(async move {
763            while let Some(event) = rx.recv().await {
764                if let CommandEvent::Terminated(payload) = event {
765                    // Process was killed by signal, so code is None and signal is Some
766                    assert!(payload.code.is_none() || payload.signal.is_some());
767                    break;
768                }
769            }
770        });
771
772        // Verify the process group is gone
773        let ret = unsafe { libc::killpg(pid as i32, 0) };
774        assert_ne!(ret, 0, "process group should no longer exist");
775    }
776
777    #[test]
778    fn test_cmd_process_group_output() {
779        // Hangs on Windows if the suspended child is never resumed.
780        #[cfg(not(windows))]
781        let cmd = Command::new("cat").args(["test/test.txt"]);
782        #[cfg(windows)]
783        let cmd = Command::new("cmd").args(["/C", "type test\\test.txt"]);
784
785        let output = tauri::async_runtime::block_on(cmd.set_process_group(true).output()).unwrap();
786
787        assert!(output.status.success());
788        assert_eq!(String::from_utf8(output.stderr).unwrap(), "");
789        assert_eq!(
790            String::from_utf8(output.stdout).unwrap().trim(),
791            "This is a test doc!"
792        );
793    }
794
795    #[test]
796    fn test_cmd_kill_after_exit() {
797        for process_group in [false, true] {
798            #[cfg(not(windows))]
799            let cmd = Command::new("true");
800            #[cfg(windows)]
801            let cmd = Command::new("cmd").args(["/C", "exit 0"]);
802            let (rx, child) = cmd.set_process_group(process_group).spawn().unwrap();
803            wait_for_terminated(rx);
804            child.kill().unwrap();
805        }
806    }
807
808    /// Runs `test/<script>.ps1` on Windows or `test/<script>.sh` elsewhere.
809    fn sim_command(script: &str) -> Command {
810        if cfg!(windows) {
811            Command::new("powershell").args([
812                "-NoProfile".to_string(),
813                "-ExecutionPolicy".to_string(),
814                "Bypass".to_string(),
815                "-File".to_string(),
816                format!("test/{script}.ps1"),
817            ])
818        } else {
819            Command::new("sh").args([format!("test/{script}.sh")])
820        }
821    }
822
823    /// Reads command output until the wrapper script reports the grandchild pid.
824    fn read_grandchild_pid(rx: &mut Receiver<CommandEvent>) -> u32 {
825        tauri::async_runtime::block_on(async {
826            let mut pid = None;
827            while let Some(event) = rx.recv().await {
828                if let CommandEvent::Stdout(line) = &event {
829                    let line_str = String::from_utf8_lossy(line);
830                    if let Some(rest) = line_str.strip_prefix("CHILD_PID=") {
831                        pid = rest.trim().parse::<u32>().ok();
832                    }
833                }
834                if pid.is_some() {
835                    break;
836                }
837            }
838            pid.expect("should have received CHILD_PID from script")
839        })
840    }
841
842    /// Asserts that the child produces a `Terminated` event.
843    fn wait_for_terminated(mut rx: Receiver<CommandEvent>) {
844        let got = tauri::async_runtime::block_on(async move {
845            while let Some(event) = rx.recv().await {
846                if let CommandEvent::Terminated(_) = event {
847                    return true;
848                }
849            }
850            false
851        });
852        assert!(got, "expected a Terminated event");
853    }
854
855    #[cfg(not(windows))]
856    fn pid_alive(pid: u32) -> bool {
857        unsafe { libc::kill(pid as i32, 0) == 0 }
858    }
859
860    #[cfg(windows)]
861    fn pid_alive(pid: u32) -> bool {
862        use windows_sys::Win32::{
863            Foundation::{CloseHandle, STILL_ACTIVE},
864            System::Threading::{
865                GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
866            },
867        };
868        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
869        if handle.is_null() {
870            return false;
871        }
872        let mut code = 0u32;
873        let alive =
874            unsafe { GetExitCodeProcess(handle, &mut code) } != 0 && code == STILL_ACTIVE as u32;
875        unsafe { CloseHandle(handle) };
876        alive
877    }
878
879    #[cfg(not(windows))]
880    fn force_kill(pid: u32) {
881        unsafe { libc::kill(pid as i32, libc::SIGKILL) };
882    }
883
884    #[cfg(windows)]
885    fn force_kill(pid: u32) {
886        use windows_sys::Win32::{
887            Foundation::CloseHandle,
888            System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess},
889        };
890        let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
891        if !handle.is_null() {
892            unsafe { TerminateProcess(handle, 1) };
893            unsafe { CloseHandle(handle) };
894        }
895    }
896
897    /// End-to-end test simulating the PyInstaller scenario from issue #1332.
898    ///
899    /// PyInstaller wraps the real application in a thin bootloader process.
900    /// Without process groups, killing the bootloader orphans the real app.
901    /// This test verifies that with `process_group` enabled, killing the
902    /// wrapper also kills the grandchild process.
903    #[test]
904    fn test_pyinstaller_simulation_without_process_group() {
905        // Without process_group: killing the wrapper does NOT kill the grandchild.
906        let (mut rx, child) = sim_command("pyinstaller_sim").spawn().unwrap();
907
908        let grandchild_pid = read_grandchild_pid(&mut rx);
909        assert!(
910            pid_alive(grandchild_pid),
911            "grandchild should be running before kill"
912        );
913
914        // Kill just the direct child (no process group)
915        child.kill().unwrap();
916        std::thread::sleep(std::time::Duration::from_millis(100));
917
918        // The grandchild is STILL alive — this is the bug
919        assert!(
920            pid_alive(grandchild_pid),
921            "grandchild should survive when process_group is off"
922        );
923
924        // Clean up the orphaned grandchild. This also closes the inherited
925        // stdout pipe, which the Terminated event is gated on.
926        force_kill(grandchild_pid);
927        wait_for_terminated(rx);
928    }
929
930    #[test]
931    fn test_pyinstaller_simulation_with_process_group() {
932        // With process_group: killing the wrapper ALSO kills the grandchild.
933        let (mut rx, child) = sim_command("pyinstaller_sim")
934            .set_process_group(true)
935            .spawn()
936            .unwrap();
937
938        let grandchild_pid = read_grandchild_pid(&mut rx);
939        assert!(
940            pid_alive(grandchild_pid),
941            "grandchild should be running before kill"
942        );
943
944        // Kill the process group
945        child.kill().unwrap();
946        std::thread::sleep(std::time::Duration::from_millis(100));
947
948        // The grandchild should now be DEAD
949        assert!(
950            !pid_alive(grandchild_pid),
951            "grandchild should be killed when process_group is on"
952        );
953
954        wait_for_terminated(rx);
955    }
956
957    /// The direct child exits right after starting a grandchild (like a launcher).
958    /// Killing the process group or job object afterwards must still kill the grandchild.
959    #[test]
960    fn test_process_group_kill_after_leader_exit() {
961        let (mut rx, child) = sim_command("launcher_sim")
962            .set_process_group(true)
963            .spawn()
964            .unwrap();
965
966        let grandchild_pid = read_grandchild_pid(&mut rx);
967
968        // Wait until the launcher has exited and been reaped.
969        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
970        while child.inner.try_wait().unwrap().is_none() {
971            assert!(
972                std::time::Instant::now() < deadline,
973                "launcher should exit on its own"
974            );
975            std::thread::sleep(std::time::Duration::from_millis(50));
976        }
977        assert!(
978            pid_alive(grandchild_pid),
979            "grandchild should outlive the launcher"
980        );
981
982        child.kill().unwrap();
983        std::thread::sleep(std::time::Duration::from_millis(100));
984
985        let alive = pid_alive(grandchild_pid);
986        if alive {
987            force_kill(grandchild_pid);
988        }
989        assert!(
990            !alive,
991            "grandchild should be killed even though the launcher already exited"
992        );
993    }
994}