Skip to main content

qframe/runtime/
detached.rs

1//! Handing the terminal to a program only until it is ready, and leaving it running.
2//!
3//! Some programs ask the user something on the terminal and then keep working in the
4//! background: `pkexec` asks for a password and becomes the privileged helper it started, which
5//! serves the application for the rest of the session through its standard input and output.
6//! A [`Handoff`](super::Handoff) waits for its program to end, so its screen would never come
7//! back. A [`DetachedHandoff`] steps aside the same way but takes the screen back as soon as the
8//! program writes its first line, and leaves it running as a [`LiveChild`].
9
10use std::ffi::OsString;
11#[cfg(not(unix))]
12use std::io;
13use std::path::PathBuf;
14use std::process::Stdio;
15#[cfg(not(unix))]
16use std::process::{Child, Command as ChildCommand, ExitStatus};
17use std::sync::Arc;
18use std::sync::mpsc::{self, RecvTimeoutError, Sender};
19use std::time::Duration;
20
21use super::command::MapFn;
22#[cfg(unix)]
23use super::foreground::Foreground;
24use super::handoff::{HandoffRequest, HandoffScreen, Program};
25use super::live_child::{self, ChildLine, LiveChild, Sink};
26use super::task::Delivery;
27
28/// How often the wait for the first line looks at the program itself: whether it ended, or
29/// stopped and must go on.
30const LOOK: Duration = Duration::from_millis(20);
31
32/// How long a program that ended is given for its output to arrive: a line written just before
33/// the end may still be on its way through the pipe.
34const LAST_WORDS: Duration = Duration::from_millis(100);
35
36type LineMessage<Msg> = Arc<dyn Fn(ChildLine) -> Msg + Send + Sync>;
37
38/// Hands the terminal to a program until it writes its first line on standard output, then
39/// takes the screen back and leaves the program running in the background as a [`LiveChild`].
40///
41/// Everything up to the first line is a [`Handoff`](super::Handoff): the screen is released,
42/// the notice printed, the program gets a process group of its own that is the terminal's
43/// foreground, so it can ask on the terminal and the keys' signals (`Ctrl-C` at a password
44/// prompt) reach it and not the application. Unlike a handoff the program's standard input and
45/// output are pipes to the application; only its standard error is the terminal. `pkexec`
46/// and `sudo` ask on the controlling terminal itself, not on standard input, so they still can.
47///
48/// The first line is the program saying it is ready: the terminal's foreground goes back to the
49/// application, the screen is taken back and drawn again in full, and
50/// [`DetachedOutcome::Detached`] arrives with the child and that line. Every later line arrives
51/// through [`DetachedHandoff::on_line`], and [`ChildLine::Ended`] after the last.
52///
53/// A program that ends before its first line, such as `pkexec` after a cancelled or wrong
54/// password (codes 126 and 127), gives [`DetachedOutcome::Finished`] as a handoff would.
55///
56/// ```
57/// use qframe::prelude::*;
58/// use qframe::runtime::{ChildLine, DetachedHandoff, DetachedOutcome, LiveChild};
59///
60/// enum Msg {
61///     Start,
62///     Started(DetachedOutcome),
63///     Helper(ChildLine),
64/// }
65///
66/// fn update(helper: &mut Option<LiveChild>, msg: Msg) -> Command<Msg> {
67///     match msg {
68///         // The helper asks for the password through pkexec, then prints `ready` and serves
69///         // one request per line until its input ends.
70///         Msg::Start => Command::handoff_detached(
71///             DetachedHandoff::new("pkexec", Msg::Started)
72///                 .args(["/usr/lib/example/helper", "--serve"])
73///                 .notice("Asking for permission to manage packages…")
74///                 .on_line(Msg::Helper),
75///         ),
76///         Msg::Started(DetachedOutcome::Detached { child, first_line: _ }) => {
77///             let _ = child.write_line("list-updates");
78///             *helper = Some(child);
79///             Command::none()
80///         }
81///         Msg::Started(_) | Msg::Helper(_) => Command::none(),
82///     }
83/// }
84/// ```
85pub struct DetachedHandoff<Msg> {
86    program: Program,
87    on_start: Box<dyn FnOnce(DetachedOutcome) -> Msg + Send>,
88    on_line: Option<LineMessage<Msg>>,
89}
90
91impl<Msg: Send + 'static> DetachedHandoff<Msg> {
92    /// Runs `program`, delivering `on_start(outcome)` once the application has the screen back:
93    /// after the program's first line, or after its end when it wrote none.
94    pub fn new(program: impl Into<OsString>, on_start: impl FnOnce(DetachedOutcome) -> Msg + Send + 'static) -> Self {
95        Self { program: Program::new(program.into()), on_start: Box::new(on_start), on_line: None }
96    }
97
98    /// Adds one argument.
99    #[must_use]
100    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
101        self.program.args.push(arg.into());
102        self
103    }
104
105    /// Adds several arguments, in order.
106    #[must_use]
107    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
108        self.program.args.extend(args.into_iter().map(Into::into));
109        self
110    }
111
112    /// Runs the program in `dir` instead of the application's working directory.
113    #[must_use]
114    pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
115        self.program.dir = Some(dir.into());
116        self
117    }
118
119    /// Sets an environment variable for the program. The rest of the environment is inherited.
120    #[must_use]
121    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
122        self.program.env.push((key.into(), value.into()));
123        self
124    }
125
126    /// A line printed on the cleared screen before the program starts, so the user knows why the
127    /// application stepped aside.
128    #[must_use]
129    pub fn notice(mut self, text: impl Into<String>) -> Self {
130        self.program.notice = Some(text.into());
131        self
132    }
133
134    /// Waits for a key press when the program ends without a first line, so what it wrote on the
135    /// terminal — why a password was refused — can be read. A program that detaches never waits
136    /// for it. Off by default.
137    #[must_use]
138    pub fn pause(mut self, pause: bool) -> Self {
139        self.program.pause = pause;
140        self
141    }
142
143    /// Turns the program's later output into messages: each line after the first as
144    /// [`ChildLine::Line`], then [`ChildLine::Ended`] once its output closed and it ended. The
145    /// lines keep coming for the program's whole life, however long after the handoff.
146    /// Without it the output is read and dropped.
147    #[must_use]
148    pub fn on_line(mut self, message: impl Fn(ChildLine) -> Msg + Send + Sync + 'static) -> Self {
149        self.on_line = Some(Arc::new(message));
150        self
151    }
152
153    /// What a test sees of this handoff.
154    pub(crate) fn request(&self) -> HandoffRequest {
155        self.program.request()
156    }
157
158    /// The message of `outcome`. A detached child's later lines go, as messages of
159    /// [`DetachedHandoff::on_line`], to `deliveries`, and each one wakes the loop.
160    pub(crate) fn finish(self, outcome: DetachedOutcome, deliveries: Sender<Delivery<Msg>>) -> Msg {
161        if let DetachedOutcome::Detached { child, .. } = &outcome {
162            let sink: Sink = match self.on_line {
163                Some(message) => Box::new(move |line| {
164                    // A line arriving after the loop has gone has nowhere to be shown; the child
165                    // is detached and outlives the application on purpose.
166                    let _ = deliveries.send(Delivery::Message(message(line)));
167                    super::signals::wake();
168                }),
169                None => Box::new(|_| {}),
170            };
171            child.attach(sink);
172        }
173        (self.on_start)(outcome)
174    }
175
176    /// The same handoff delivering `map(message)` for both of its messages.
177    pub(crate) fn map<B: Send + 'static>(self, map: MapFn<Msg, B>) -> DetachedHandoff<B> {
178        let on_start = self.on_start;
179        let on_line = self.on_line.map(|message| {
180            let map = Arc::clone(&map);
181            Arc::new(move |line| map(message(line))) as LineMessage<B>
182        });
183        DetachedHandoff { program: self.program, on_start: Box::new(move |outcome| map(on_start(outcome))), on_line }
184    }
185}
186
187/// How a [`DetachedHandoff`] ended.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum DetachedOutcome {
190    /// The program wrote its first line and runs on in the background.
191    Detached {
192        /// The running program.
193        child: LiveChild,
194        /// Its first line, without the newline.
195        first_line: String,
196    },
197    /// The program ended without a first line; `code` is `None` when a signal ended it.
198    Finished {
199        /// The exit code, or `None` after a signal such as an interrupt.
200        code: Option<i32>,
201    },
202    /// The program could not be started, or the terminal could not be restored.
203    Failed(String),
204}
205
206/// Runs `handoff` on the calling thread until its program writes its first line or ends, and
207/// returns the message of its outcome. The screen is left and taken back through `screen`, as
208/// for a [`Handoff`](super::Handoff).
209pub(crate) fn run<Msg: Send + 'static>(
210    handoff: DetachedHandoff<Msg>,
211    screen: &mut HandoffScreen<'_>,
212    deliveries: &Sender<Delivery<Msg>>,
213) -> Msg {
214    let outcome = match (screen.release)(handoff.program.notice.as_deref()) {
215        Ok(()) => {
216            let outcome = start(&handoff.program);
217            if handoff.program.pause && matches!(outcome, DetachedOutcome::Finished { .. }) {
218                // The pause is a courtesy, so the person can read what the program left before
219                // the application takes the screen back. A keyboard that cannot be read means
220                // there was nobody to wait for, and going straight on is the better answer.
221                let _ = (screen.wait_for_key)();
222            }
223            match (screen.take)() {
224                Ok(()) => outcome,
225                // A child whose screen could not come back is dropped with the outcome: its
226                // input closes and it ends.
227                Err(error) => DetachedOutcome::Failed(error.to_string()),
228            }
229        }
230        Err(error) => {
231            // Application mode may be half gone; taking the screen back puts it right.
232            let _ = (screen.take)();
233            DetachedOutcome::Failed(error.to_string())
234        }
235    };
236    handoff.finish(outcome, deliveries.clone())
237}
238
239/// Starts the program with the terminal's foreground and pipes for its input and output, and
240/// waits for its first line or its end.
241fn start(program: &Program) -> DetachedOutcome {
242    // Three paths below give up on a child that started but cannot be used, and each ends it the
243    // same way: the kill fails only on a child that ended by itself, and the wait collects it so
244    // nothing is left behind. Neither has an answer to add — the `Failed` outcome returned beside
245    // it already carries the reason the person needs.
246    let mut command = program.command();
247    command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit());
248    let (mut child, foreground) = match Foreground::spawn(&mut command) {
249        Ok(started) => started,
250        Err(error) => return DetachedOutcome::Failed(error.to_string()),
251    };
252    drop(command);
253    let (Some(stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
254        let _ = child.kill();
255        let _ = foreground.wait(&mut child);
256        return DetachedOutcome::Failed("the program's pipes could not be opened".to_owned());
257    };
258    let (first_sender, first) = mpsc::sync_channel(1);
259    let (attach, attached) = mpsc::sync_channel(1);
260    let reader = std::thread::Builder::new()
261        .name("quvyta-live-child".to_owned())
262        .spawn(move || live_child::read(stdout, &first_sender, &attached));
263    if let Err(error) = reader {
264        let _ = child.kill();
265        let _ = foreground.wait(&mut child);
266        return DetachedOutcome::Failed(error.to_string());
267    }
268    let first_line = loop {
269        match first.recv_timeout(LOOK) {
270            Ok(line) => break line,
271            Err(RecvTimeoutError::Disconnected) => break None,
272            Err(RecvTimeoutError::Timeout) => match foreground.check(&mut child) {
273                Ok(None) => {}
274                Ok(Some(_)) => break first.recv_timeout(LAST_WORDS).ok().flatten(),
275                Err(error) => {
276                    let _ = child.kill();
277                    let _ = foreground.wait(&mut child);
278                    return DetachedOutcome::Failed(error.to_string());
279                }
280            },
281        }
282    };
283    match first_line {
284        Some(first_line) => {
285            // The program runs on in its own group, which is now the terminal's background.
286            let child = LiveChild::running(child, stdin, attach);
287            match foreground.give_back() {
288                Ok(()) => DetachedOutcome::Detached { child, first_line },
289                Err(error) => DetachedOutcome::Failed(error.to_string()),
290            }
291        }
292        None => {
293            // Without its output there is nothing to detach; the closed input tells a program
294            // that still reads it to finish, and it ends as a handoff's program would.
295            drop(stdin);
296            let status = foreground.wait(&mut child);
297            let taken = foreground.give_back();
298            match (status, taken) {
299                (Ok(status), Ok(())) => DetachedOutcome::Finished { code: status.code() },
300                (Err(error), _) | (_, Err(error)) => DetachedOutcome::Failed(error.to_string()),
301            }
302        }
303    }
304}
305
306/// Without Unix there is no terminal foreground to lend: the program is only started, and
307/// waited for as any child.
308#[cfg(not(unix))]
309struct Foreground;
310
311#[cfg(not(unix))]
312impl Foreground {
313    fn spawn(command: &mut ChildCommand) -> io::Result<(Child, Self)> {
314        Ok((command.spawn()?, Self))
315    }
316
317    fn wait(&self, child: &mut Child) -> io::Result<ExitStatus> {
318        child.wait()
319    }
320
321    fn check(&self, child: &mut Child) -> io::Result<Option<ExitStatus>> {
322        child.try_wait()
323    }
324
325    fn give_back(self) -> io::Result<()> {
326        Ok(())
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use std::cell::RefCell;
333    use std::io;
334    use std::sync::mpsc::{self, Receiver};
335    use std::time::Duration;
336
337    use super::{DetachedHandoff, DetachedOutcome, run};
338    use crate::runtime::ChildLine;
339    use crate::runtime::handoff::HandoffScreen;
340    use crate::runtime::task::Delivery;
341
342    /// How long a line may take to arrive on a loaded machine.
343    const PATIENCE: Duration = Duration::from_secs(30);
344
345    /// What the application hears: the outcome, then the child's lines.
346    #[derive(Debug, PartialEq)]
347    enum Heard {
348        Started(DetachedOutcome),
349        Said(ChildLine),
350    }
351
352    /// Runs `script` through `sh` as a detached handoff against a stand-in screen. Returns the
353    /// outcome, what was done to the screen, in order, and where the child's later lines arrive.
354    fn detach(
355        handoff: DetachedHandoff<Heard>,
356        release_fails: bool,
357    ) -> (DetachedOutcome, Vec<String>, Receiver<Delivery<Heard>>) {
358        let steps = RefCell::new(Vec::new());
359        let mut release = |notice: Option<&str>| -> io::Result<()> {
360            steps.borrow_mut().push(notice.map_or_else(|| "release".to_owned(), |text| format!("release {text}")));
361            if release_fails { Err(io::Error::other("no terminal")) } else { Ok(()) }
362        };
363        let mut take = || -> io::Result<()> {
364            steps.borrow_mut().push("take".to_owned());
365            Ok(())
366        };
367        let mut wait_for_key = || -> io::Result<()> {
368            steps.borrow_mut().push("key".to_owned());
369            Ok(())
370        };
371        let (deliveries, lines) = mpsc::channel();
372        let message = run(
373            handoff,
374            &mut HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key },
375            &deliveries,
376        );
377        let Heard::Started(outcome) = message else {
378            panic!("the handoff delivers its outcome first: {message:?}");
379        };
380        (outcome, steps.into_inner(), lines)
381    }
382
383    fn shell(script: &str) -> DetachedHandoff<Heard> {
384        DetachedHandoff::new("sh", Heard::Started).args(["-c", script]).on_line(Heard::Said)
385    }
386
387    /// The next line the child said.
388    fn next(lines: &Receiver<Delivery<Heard>>) -> ChildLine {
389        match lines.recv_timeout(PATIENCE) {
390            Ok(Delivery::Message(Heard::Said(line))) => line,
391            Ok(Delivery::Message(other)) => panic!("only lines follow the outcome: {other:?}"),
392            Ok(Delivery::Ended) => panic!("a child's lines are not background work that ends"),
393            Err(error) => panic!("no line arrived: {error}"),
394        }
395    }
396
397    #[test]
398    fn the_first_line_brings_the_screen_back_and_the_child_runs_on() {
399        let (outcome, steps, lines) = detach(shell("echo ready; echo more; cat").notice("Starting the helper"), false);
400        let DetachedOutcome::Detached { child, first_line } = outcome else {
401            panic!("the program said it was ready: {outcome:?}");
402        };
403        assert_eq!(first_line, "ready");
404        assert_eq!(steps, ["release Starting the helper", "take"], "the screen came back while the child runs");
405        assert_eq!(child.try_wait().expect("its state"), None, "the child is still running");
406        assert_eq!(next(&lines), ChildLine::Line("more".to_owned()), "a line right after the first is kept");
407        child.write_line("ping").expect("the child reads its input");
408        assert_eq!(next(&lines), ChildLine::Line("ping".to_owned()), "what the application wrote reached the child");
409        child.write_line("päckage ünïcode").expect("the child reads its input");
410        assert_eq!(next(&lines), ChildLine::Line("päckage ünïcode".to_owned()));
411        child.close_stdin();
412        assert_eq!(next(&lines), ChildLine::Ended { code: Some(0) }, "`cat` ended at the end of its input");
413        assert_eq!(child.try_wait().expect("its state"), Some(Some(0)));
414        let refused = child.write_line("late").expect_err("the input is closed");
415        assert_eq!(refused.kind(), io::ErrorKind::BrokenPipe);
416    }
417
418    #[test]
419    fn dropping_the_last_clone_closes_the_childs_input() {
420        let (outcome, _, lines) = detach(shell("echo ready; cat; echo bye"), false);
421        let DetachedOutcome::Detached { child, .. } = outcome else {
422            panic!("the program said it was ready: {outcome:?}");
423        };
424        let kept = child.clone();
425        drop(child);
426        kept.write_line("still open").expect("a clone keeps the input open");
427        assert_eq!(next(&lines), ChildLine::Line("still open".to_owned()));
428        // The application's state goes when its run ends, and the child with it.
429        drop(kept);
430        assert_eq!(next(&lines), ChildLine::Line("bye".to_owned()), "the child read the end of its input");
431        assert_eq!(next(&lines), ChildLine::Ended { code: Some(0) });
432    }
433
434    #[test]
435    fn a_program_that_ends_before_its_first_line_finishes_as_a_handoff_would() {
436        let (outcome, steps, _) = detach(shell("exit 126"), false);
437        assert_eq!(outcome, DetachedOutcome::Finished { code: Some(126) }, "pkexec's code for a refused password");
438        assert_eq!(steps, ["release", "take"]);
439        // Standard error is the terminal, not the pipe, so what it says there is no first line.
440        let (outcome, _, _) = detach(shell("echo refused >&2; exit 127"), false);
441        assert_eq!(outcome, DetachedOutcome::Finished { code: Some(127) });
442        let (outcome, _, _) = detach(shell("kill -TERM $$"), false);
443        assert_eq!(outcome, DetachedOutcome::Finished { code: None }, "a signal leaves no code");
444    }
445
446    #[test]
447    fn a_program_that_closes_its_output_is_waited_for() {
448        let (outcome, _, _) = detach(shell("exec >&-; sleep 0.2; exit 4"), false);
449        assert_eq!(outcome, DetachedOutcome::Finished { code: Some(4) }, "the end of the output is not the end");
450    }
451
452    #[test]
453    fn a_line_said_just_before_the_end_still_detaches_and_the_end_follows() {
454        let (outcome, _, lines) = detach(shell("echo ready; exit 5"), false);
455        let DetachedOutcome::Detached { first_line, .. } = outcome else {
456            panic!("the line came first: {outcome:?}");
457        };
458        assert_eq!(first_line, "ready");
459        assert_eq!(next(&lines), ChildLine::Ended { code: Some(5) });
460    }
461
462    #[test]
463    fn a_child_can_be_killed() {
464        let (outcome, _, lines) = detach(shell("echo ready; exec sleep 30"), false);
465        let DetachedOutcome::Detached { child, .. } = outcome else {
466            panic!("the program said it was ready: {outcome:?}");
467        };
468        assert!(child.id().is_some(), "a real child has a process id");
469        child.kill().expect("our own child may be killed");
470        assert_eq!(next(&lines), ChildLine::Ended { code: None }, "killed by a signal");
471    }
472
473    #[test]
474    fn pause_waits_for_a_key_only_when_the_program_ended_without_detaching() {
475        let (_, finished, _) = detach(shell("exit 1").pause(true), false);
476        assert_eq!(finished, ["release", "key", "take"], "the reason can be read before the screen comes back");
477        let (outcome, detached, _) = detach(shell("echo ready; cat").pause(true), false);
478        assert!(matches!(outcome, DetachedOutcome::Detached { .. }), "{outcome:?}");
479        assert_eq!(detached, ["release", "take"], "a program that is ready leaves nothing to read");
480    }
481
482    #[test]
483    fn an_unstartable_program_fails_and_the_screen_still_comes_back() {
484        let (outcome, steps, _) = detach(DetachedHandoff::new("quvyta-no-such-program", Heard::Started), false);
485        let DetachedOutcome::Failed(reason) = outcome else {
486            panic!("a program that is not there cannot have started: {outcome:?}");
487        };
488        assert!(!reason.is_empty(), "the reason names what went wrong");
489        assert_eq!(steps, ["release", "take"]);
490    }
491
492    #[test]
493    fn a_terminal_that_cannot_be_released_fails_without_running_the_program() {
494        let (outcome, steps, _) = detach(shell("echo ready; cat"), true);
495        assert_eq!(outcome, DetachedOutcome::Failed("no terminal".to_owned()));
496        assert_eq!(steps, ["release", "take"], "application mode is put back");
497    }
498
499    #[test]
500    fn arguments_the_directory_and_the_environment_reach_the_program() {
501        let handoff = DetachedHandoff::new("sh", Heard::Started)
502            .arg("-c")
503            .arg(r#"echo "$(pwd) $QUVYTA_DETACHED_TEST"; cat"#)
504            .dir("/")
505            .env("QUVYTA_DETACHED_TEST", "ok");
506        let request = handoff.request();
507        assert_eq!(request.program, std::ffi::OsString::from("sh"));
508        assert!(!request.pause);
509        let (outcome, _, _) = detach(handoff, false);
510        let DetachedOutcome::Detached { first_line, .. } = outcome else {
511            panic!("the program said it was ready: {outcome:?}");
512        };
513        assert_eq!(first_line, "/ ok");
514    }
515}