@@ -24,6 +24,7 @@ pub(crate) struct Process {
2424 status: Option<ExitStatus>,
2525}
2626
27+#[derive(Debug)]
2728pub(crate) struct Outcome {
2829 pub success: bool,
2930 pub code: i32,
@@ -42,27 +43,7 @@ pub(crate) fn run_application(
4243 if interrupted.load(Ordering::Relaxed) {
4344 return Err(failure("interrupted"));
4445 }
45− let mut command = Command::new(executable);
46− command
47− .args(arguments)
48− .stdin(if source.is_some() {
49− Stdio::piped()
50− } else {
51− Stdio::null()
52− })
53− .stdout(Stdio::inherit())
54− .stderr(Stdio::inherit());
55− #[cfg(unix)]
56− {
57− use std::os::unix::process::CommandExt;
58− command.process_group(0);
59− }
60− #[cfg(windows)]
61− {
62− use std::os::windows::process::CommandExt;
63− // CREATE_NEW_PROCESS_GROUP disables inherited console CTRL_C events.
64− command.creation_flags(0x0000_0200);
65− }
46+ let mut command = application_command(executable, arguments, source.is_some());
6647 let executable = executable.display().to_string();
6748 let (complete, outcome) = mpsc::channel();
6849 let launch_interrupted = interrupted.clone();
@@ -132,6 +113,241 @@ pub(crate) fn run_application(
132113 }
133114}
134115
116+/// Launch an application task whose source delivery and descendants are owned
117+/// by this call. Unlike `run_application`, it never detaches from its child.
118+pub(crate) fn run_application_task(
119+ executable: &Path,
120+ arguments: &[String],
121+ source: Option<String>,
122+ interrupted: Arc<AtomicBool>,
123+) -> HostResult<Outcome> {
124+ if interrupted.load(Ordering::Relaxed) {
125+ return Err(failure("interrupted"));
126+ }
127+ let command = application_command(executable, arguments, source.is_some());
128+ let mut task = ApplicationTask::spawn(command, source)?;
129+ task.wait(interrupted, executable)
130+}
131+
132+fn application_command(executable: &Path, arguments: &[String], has_source: bool) -> Command {
133+ let mut command = Command::new(executable);
134+ command
135+ .args(arguments)
136+ .stdin(if has_source {
137+ Stdio::piped()
138+ } else {
139+ Stdio::null()
140+ })
141+ .stdout(Stdio::inherit())
142+ .stderr(Stdio::inherit());
143+ #[cfg(unix)]
144+ {
145+ use std::os::unix::process::CommandExt;
146+ command.process_group(0);
147+ }
148+ #[cfg(windows)]
149+ {
150+ use std::os::windows::process::CommandExt;
151+ // CREATE_NEW_PROCESS_GROUP disables inherited console CTRL_C events.
152+ command.creation_flags(0x0000_0200);
153+ }
154+ command
155+}
156+
157+struct TaskInput {
158+ complete: mpsc::Receiver<Result<(), String>>,
159+ writer: JoinHandle<()>,
160+ done: bool,
161+ failure: Option<String>,
162+}
163+
164+/// A task owns both its child group and its stdin writer. Keeping the writer
165+/// separate makes a blocked pipe interruptible without copying the source.
166+struct ApplicationTask {
167+ child: Child,
168+ input: Option<TaskInput>,
169+ status: Option<ExitStatus>,
170+}
171+
172+impl ApplicationTask {
173+ fn spawn(mut command: Command, source: Option<String>) -> HostResult<Self> {
174+ let mut child = command.spawn().map_err(|error| {
175+ failure(format!(
176+ "{}: {error}",
177+ command.get_program().to_string_lossy()
178+ ))
179+ })?;
180+ let input = match source {
181+ None => None,
182+ Some(source) => {
183+ let mut stdin = match child.stdin.take() {
184+ Some(stdin) => stdin,
185+ None => {
186+ kill_process_group(&mut child);
187+ let _ = child.wait();
188+ return Err(failure("application stdin pipe is unavailable"));
189+ }
190+ };
191+ let (delivered, complete) = mpsc::sync_channel(1);
192+ let writer = match thread::Builder::new()
193+ .name("native-application-stdin".into())
194+ .spawn(move || {
195+ let result = stdin
196+ .write_all(source.as_bytes())
197+ .map_err(|error| error.to_string());
198+ let _ = delivered.send(result);
199+ }) {
200+ Ok(writer) => writer,
201+ Err(error) => {
202+ kill_process_group(&mut child);
203+ let _ = child.wait();
204+ return Err(error.into());
205+ }
206+ };
207+ Some(TaskInput {
208+ complete,
209+ writer,
210+ done: false,
211+ failure: None,
212+ })
213+ }
214+ };
215+ Ok(Self {
216+ child,
217+ input,
218+ status: None,
219+ })
220+ }
221+
222+ fn wait(&mut self, interrupted: Arc<AtomicBool>, executable: &Path) -> HostResult<Outcome> {
223+ loop {
224+ if interrupted.load(Ordering::Relaxed) {
225+ self.cancel()?;
226+ return Err(failure("interrupted"));
227+ }
228+ if let Some(status) = self.child.try_wait()? {
229+ self.status = Some(status);
230+ // Descendants may retain stdin after the leader exits. The task
231+ // owns that group, so close it before joining the writer.
232+ kill_process_group(&mut self.child);
233+ let delivered = self.wait_input();
234+ if status.success() {
235+ if let Err(error) = delivered {
236+ return Err(failure(format!(
237+ "writing {} stdin: {error}",
238+ executable.display()
239+ )));
240+ }
241+ }
242+ return Ok(Outcome {
243+ success: status.success(),
244+ code: status.code().unwrap_or(-1),
245+ stderr: String::new(),
246+ });
247+ }
248+ if let Some(error) = self.poll_input_failure() {
249+ self.cancel()?;
250+ return Err(failure(format!(
251+ "writing {} stdin: {error}",
252+ executable.display()
253+ )));
254+ }
255+ thread::sleep(POLL);
256+ }
257+ }
258+
259+ fn poll_input_failure(&mut self) -> Option<String> {
260+ let input = self.input.as_mut()?;
261+ if !input.done {
262+ match input.complete.try_recv() {
263+ Ok(Ok(())) => input.done = true,
264+ Ok(Err(error)) => {
265+ input.done = true;
266+ input.failure = Some(error);
267+ }
268+ Err(mpsc::TryRecvError::Empty) => {}
269+ Err(mpsc::TryRecvError::Disconnected) => {
270+ input.done = true;
271+ input.failure = Some("application stdin writer stopped".into());
272+ }
273+ }
274+ }
275+ input.failure.take()
276+ }
277+
278+ fn wait_input(&mut self) -> HostResult<()> {
279+ let Some(mut input) = self.input.take() else {
280+ return Ok(());
281+ };
282+ input
283+ .writer
284+ .join()
285+ .map_err(|_| failure("application stdin writer panicked"))?;
286+ if !input.done {
287+ input.done = true;
288+ input.failure = match input.complete.recv() {
289+ Ok(Ok(())) => None,
290+ Ok(Err(error)) => Some(error),
291+ Err(_) => Some("application stdin writer stopped".into()),
292+ };
293+ }
294+ match input.failure.take() {
295+ Some(error) => Err(failure(error)),
296+ None => Ok(()),
297+ }
298+ }
299+
300+ fn cancel(&mut self) -> HostResult<()> {
301+ kill_process_group(&mut self.child);
302+ let waited = if self.status.is_none() {
303+ self.child.wait().map(|status| {
304+ self.status = Some(status);
305+ })
306+ } else {
307+ Ok(())
308+ };
309+ // The delivery result is reported by wait(). Cancellation still joins
310+ // this writer so no blocked stdin thread can outlive the task.
311+ let _ = self.wait_input();
312+ if let Err(error) = waited {
313+ return Err(error.into());
314+ }
315+ Ok(())
316+ }
317+}
318+
319+impl Drop for ApplicationTask {
320+ fn drop(&mut self) {
321+ if self.status.is_none() {
322+ let _ = self.cancel();
323+ }
324+ }
325+}
326+
327+fn kill_process_group(child: &mut Child) {
328+ #[cfg(unix)]
329+ {
330+ // The group ID was created by Command::process_group(0) for this
331+ // owned child. Never signal the parent's terminal process group.
332+ unsafe {
333+ libc::kill(-(child.id() as i32), libc::SIGKILL);
334+ }
335+ }
336+ #[cfg(windows)]
337+ {
338+ // CREATE_NEW_PROCESS_GROUP does not make Child::kill recursive.
339+ // taskkill terminates the child tree before the writer is joined.
340+ let child_id = child.id().to_string();
341+ let _ = Command::new("taskkill")
342+ .args(["/PID", child_id.as_str(), "/T", "/F"])
343+ .stdin(Stdio::null())
344+ .stdout(Stdio::null())
345+ .stderr(Stdio::null())
346+ .status();
347+ }
348+ let _ = child.kill();
349+}
350+
135351impl Process {
136352 pub fn spawn(
137353 executable: &Path,
@@ -329,3 +545,219 @@ impl Drop for Process {
329545 }
330546 }
331547}
548+
549+#[cfg(all(test, unix))]
550+mod tests {
551+ use super::{application_command, run_application, run_application_task, ApplicationTask};
552+ use std::fs;
553+ use std::io;
554+ use std::path::{Path, PathBuf};
555+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
556+ use std::sync::Arc;
557+ use std::thread;
558+ use std::time::{Duration, Instant};
559+
560+ static NEXT_TEMPORARY_DIRECTORY: AtomicU64 = AtomicU64::new(0);
561+ const TEST_TIMEOUT: Duration = Duration::from_secs(2);
562+
563+ struct TemporaryDirectory(PathBuf);
564+
565+ impl TemporaryDirectory {
566+ fn new() -> Self {
567+ let suffix = NEXT_TEMPORARY_DIRECTORY.fetch_add(1, Ordering::Relaxed);
568+ let path = std::env::temp_dir().join(format!(
569+ "tauri-native-process-test-{}-{suffix}",
570+ std::process::id()
571+ ));
572+ fs::create_dir(&path).expect("temporary directory is available");
573+ Self(path)
574+ }
575+
576+ fn file(&self, name: &str) -> PathBuf {
577+ self.0.join(name)
578+ }
579+ }
580+
581+ impl Drop for TemporaryDirectory {
582+ fn drop(&mut self) {
583+ let _ = fs::remove_dir_all(&self.0);
584+ }
585+ }
586+
587+ fn shell(script: String) -> Vec<String> {
588+ vec!["-c".into(), script]
589+ }
590+
591+ fn wait_for_file(path: &Path) -> String {
592+ let deadline = Instant::now() + TEST_TIMEOUT;
593+ loop {
594+ if let Ok(contents) = fs::read_to_string(path) {
595+ if !contents.is_empty() {
596+ return contents;
597+ }
598+ }
599+ assert!(
600+ Instant::now() < deadline,
601+ "timed out waiting for {}",
602+ path.display()
603+ );
604+ thread::sleep(Duration::from_millis(10));
605+ }
606+ }
607+
608+ fn assert_group_is_gone(group: i32) {
609+ let deadline = Instant::now() + TEST_TIMEOUT;
610+ loop {
611+ if unsafe { libc::kill(-group, 0) } == -1
612+ && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
613+ {
614+ return;
615+ }
616+ assert!(
617+ Instant::now() < deadline,
618+ "owned process group {group} survived cancellation"
619+ );
620+ thread::sleep(Duration::from_millis(10));
621+ }
622+ }
623+
624+ fn recorded_group(contents: &str) -> i32 {
625+ contents
626+ .trim()
627+ .split_whitespace()
628+ .next()
629+ .expect("shell recorded its process group leader")
630+ .parse()
631+ .expect("shell process group leader is numeric")
632+ }
633+
634+ #[test]
635+ fn application_task_returns_child_exit_status() {
636+ let interrupted = Arc::new(AtomicBool::new(false));
637+ let success = run_application_task(
638+ Path::new("/bin/sh"),
639+ &shell("exit 0".into()),
640+ None,
641+ interrupted.clone(),
642+ )
643+ .expect("successful task exits normally");
644+ assert!(success.success);
645+ assert_eq!(success.code, 0);
646+
647+ let failure = run_application_task(
648+ Path::new("/bin/sh"),
649+ &shell("exit 17".into()),
650+ None,
651+ interrupted,
652+ )
653+ .expect("nonzero task exit is reported");
654+ assert!(!failure.success);
655+ assert_eq!(failure.code, 17);
656+ }
657+
658+ #[test]
659+ fn application_task_preserves_rejection_during_source_delivery() {
660+ let executable = Path::new("/bin/sh");
661+ let command = application_command(executable, &shell("exit 17".into()), true);
662+ let mut task = ApplicationTask::spawn(command, Some("source".repeat(128 * 1024)))
663+ .expect("task starts with piped input");
664+ task.child.wait().expect("child rejects the request");
665+ let outcome = task
666+ .wait(Arc::new(AtomicBool::new(false)), executable)
667+ .expect("child rejection is not replaced by a broken stdin error");
668+ assert!(!outcome.success);
669+ assert_eq!(outcome.code, 17);
670+ }
671+
672+ #[test]
673+ fn interrupted_task_reaps_its_process_group() {
674+ let temporary = TemporaryDirectory::new();
675+ let pids = temporary.file("pids");
676+ let arguments = shell(format!(
677+ "sleep 30 & child=$!; printf '%s %s' \"$$\" \"$child\" > '{}'; wait",
678+ pids.display()
679+ ));
680+ let interrupted = Arc::new(AtomicBool::new(false));
681+ let wait_flag = interrupted.clone();
682+ let wait = thread::spawn(move || {
683+ run_application_task(
684+ Path::new("/bin/sh"),
685+ &arguments,
686+ Some("source".repeat(1024 * 1024)),
687+ wait_flag,
688+ )
689+ .map_err(|error| error.to_string())
690+ });
691+ let group = recorded_group(&wait_for_file(&pids));
692+ interrupted.store(true, Ordering::Relaxed);
693+ let error = wait
694+ .join()
695+ .expect("task wait thread does not panic")
696+ .expect_err("interrupt fails the owned task");
697+ assert_eq!(error.to_string(), "interrupted");
698+ assert_group_is_gone(group);
699+ }
700+
701+ #[test]
702+ fn source_delivery_failure_reaps_its_process_group() {
703+ let temporary = TemporaryDirectory::new();
704+ let pids = temporary.file("pids");
705+ let close_stdin = temporary.file("close-stdin");
706+ let arguments = shell(format!(
707+ "sleep 30 <&- & child=$!; printf '%s %s' \"$$\" \"$child\" > '{}'; \
708+ while [ ! -f '{}' ]; do sleep 0.01; done; exec 0<&-; wait",
709+ pids.display(),
710+ close_stdin.display()
711+ ));
712+ let wait = thread::spawn(move || {
713+ run_application_task(
714+ Path::new("/bin/sh"),
715+ &arguments,
716+ Some("source".repeat(1024 * 1024)),
717+ Arc::new(AtomicBool::new(false)),
718+ )
719+ .map_err(|error| error.to_string())
720+ });
721+ let group = recorded_group(&wait_for_file(&pids));
722+ fs::write(close_stdin, "").expect("close-stdin sentinel is writable");
723+ let error = wait
724+ .join()
725+ .expect("task wait thread does not panic")
726+ .expect_err("closed stdin fails source delivery");
727+ assert!(
728+ error.to_string().starts_with("writing /bin/sh stdin:"),
729+ "unexpected source error: {error}"
730+ );
731+ assert_group_is_gone(group);
732+ }
733+
734+ #[test]
735+ fn interactive_application_detaches_on_interrupt() {
736+ let temporary = TemporaryDirectory::new();
737+ let started = temporary.file("started");
738+ let continue_application = temporary.file("continue");
739+ let finished = temporary.file("finished");
740+ let arguments = shell(format!(
741+ "printf started > '{}'; while [ ! -f '{}' ]; do sleep 0.01; done; \
742+ printf finished > '{}'",
743+ started.display(),
744+ continue_application.display(),
745+ finished.display()
746+ ));
747+ let interrupted = Arc::new(AtomicBool::new(false));
748+ let wait_flag = interrupted.clone();
749+ let wait = thread::spawn(move || {
750+ run_application(Path::new("/bin/sh"), &arguments, None, wait_flag)
751+ .map_err(|error| error.to_string())
752+ });
753+ wait_for_file(&started);
754+ interrupted.store(true, Ordering::Relaxed);
755+ let error = wait
756+ .join()
757+ .expect("interactive wait thread does not panic")
758+ .expect_err("interrupt returns from the interactive wait");
759+ assert_eq!(error.to_string(), "interrupted");
760+ fs::write(continue_application, "").expect("continue sentinel is writable");
761+ assert_eq!(wait_for_file(&finished), "finished");
762+ }
763+}