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