git.iko.soy/rowtype-yoga/purescript-tauri
on main help sign in
Supervise one-shot native application tasks
commit14c4526fb75ea42590618c63da93373ef4b45432 authorMark <mark.eibes@gmail.com> dateMon 5 Oct 2026, 10:58 (1 week ago) parenta007050
4 files changed+498−21
native/runtime/src/lib.rs+28 native/runtime/src/process.rs+453−21 src/Tauri/Native.js+2 src/Tauri/Native.purs+15
native/runtime/src/lib.rs+28
@@ -215,6 +215,19 @@ impl Host {
215215 )
216216 }
217217
218+ fn application_task(
219+ &self,
220+ args: Vec<String>,
221+ source: Option<String>,
222+ ) -> HostResult<process::Outcome> {
223+ process::run_application_task(
224+ &self.config.application,
225+ &args,
226+ source,
227+ self.interrupted.clone(),
228+ )
229+ }
230+
218231 fn sleep(&self, milliseconds: i32) -> HostResult<()> {
219232 if milliseconds < 0 {
220233 return Err(failure("sleep duration must not be negative"));
@@ -347,6 +360,18 @@ fn dispatch<'js>(
347360 .unwrap_or_default()
348361 .into_js(ctx),
349362 "monotonicMilliseconds" => (host.started.elapsed().as_secs_f64() * 1000.0).into_js(ctx),
363+ "platform" => std::env::consts::OS.into_js(ctx),
364+ "runApplicationTask" => {
365+ let outcome = native(
366+ ctx,
367+ host.application_task(arg!(0, Vec<String>), arg!(1, Option<String>)),
368+ )?;
369+ let value = Object::new(ctx.clone())?;
370+ value.set("success", outcome.success)?;
371+ value.set("code", outcome.code)?;
372+ value.set("stderr", outcome.stderr)?;
373+ value.into_js(ctx)
374+ }
350375 "runApplication" => {
351376 let outcome = native(
352377 ctx,
@@ -557,6 +582,9 @@ static RUN_LOCK: Mutex<()> = Mutex::new(());
557582static INTERRUPT: LazyLock<Result<Arc<AtomicBool>, String>> = LazyLock::new(|| {
558583 let flag = Arc::new(AtomicBool::new(false));
559584 let handler_flag = flag.clone();
585+ // ctrlc's `termination` feature routes SIGINT and SIGTERM here on Unix.
586+ // They intentionally share one cancellation state; native calls cannot
587+ // distinguish which signal requested shutdown.
560588 ctrlc::set_handler(move || {
561589 handler_flag.store(true, Ordering::Relaxed);
562590 })
native/runtime/src/process.rs+453−21
@@ -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+}
src/Tauri/Native.js+2
@@ -16,6 +16,8 @@ export const stderr = text => () => { host().call("stderr", text); };
1616export const absolutePath = path => () => host().call("absolutePath", path);
1717export const environment = name => () => host().call("environment", name);
1818export const monotonicMilliseconds = () => host().call("monotonicMilliseconds");
19+export const platform = () => host().call("platform");
20+export const runApplicationTaskImpl = args => input => () => host().call("runApplicationTask", args, input);
1921export const runApplicationImpl = args => input => () => host().call("runApplication", args, input);
2022export const measureText = font => text => () => host().call("measureText", font, text);
2123export const beginVideo = options => () => host().call("beginVideo", options);
src/Tauri/Native.purs+15
@@ -15,6 +15,8 @@ module Tauri.Native
1515 , absolutePath
1616 , environment
1717 , monotonicMilliseconds
18+ , platform
19+ , runApplicationTask
1820 , runApplication
1921 , measureText
2022 , beginVideo
@@ -39,6 +41,15 @@ type Metrics = { width :: Number, ascent :: Number, descent :: Number }
3941
4042type ProcessResult = { success :: Boolean, code :: Int, stderr :: String }
4143
44+-- | Launch the configured sibling application with literal arguments. Source
45+-- | data, when present, is sent through stdin and never becomes an argument.
46+-- | Output and the working directory are inherited. Waits for the task to exit
47+-- | and reaps it; any remaining descendants in its process group are terminated.
48+-- | Interrupts and source-delivery failures terminate the task and its group,
49+-- | unlike `runApplication`, whose application survives an interrupted wait.
50+runApplicationTask :: Array String -> Maybe String -> Effect ProcessResult
51+runApplicationTask args input = runApplicationTaskImpl args (toNullable input)
52+
4253-- | Launch the configured sibling application with literal arguments. Source
4354-- | data, when present, is sent through stdin and never becomes an argument.
4455-- | Output is inherited; interrupting this wait does not terminate the app.
@@ -58,6 +69,10 @@ foreign import stderr :: String -> Effect Unit
5869foreign import absolutePath :: String -> Effect String
5970foreign import environment :: String -> Effect String
6071foreign import monotonicMilliseconds :: Effect Number
72+-- | The operating-system identifier used by Rust (`macos`, `linux`, `windows`,
73+-- | and so on).
74+foreign import platform :: Effect String
75+foreign import runApplicationTaskImpl :: Array String -> Nullable String -> Effect ProcessResult
6176foreign import runApplicationImpl :: Array String -> Nullable String -> Effect ProcessResult
6277foreign import measureText :: String -> String -> Effect Metrics
6378foreign import beginVideo :: { output :: String, fps :: Int, width :: Int, height :: Int } -> Effect Video