Skip to main content

qframe/runtime/
handoff.rs

1//! Handing the terminal to another program for a while.
2//!
3//! An application that draws on the alternate screen in raw mode cannot show another program's
4//! prompts: `sudo` writes its password prompt to the controlling terminal, our screen swallows
5//! it, and both programs read the same keys. The answer is to step aside: leave raw mode and the
6//! alternate screen, let the program own the terminal, then take the screen back and draw
7//! everything again. The program gets a process group of its own for the time, so the keys'
8//! signals reach it and not the application.
9//!
10//! [`Handoff`] describes such a step aside, and [`run`] carries it out with the screen the caller
11//! hands it: the real terminal in [`Runtime`](super::Runtime), a recording stand-in in tests.
12
13use std::ffi::OsString;
14use std::io;
15use std::path::PathBuf;
16use std::process::{Command as Child, Stdio};
17
18/// Hands the terminal to another program: leaves raw mode and the alternate screen, runs it
19/// attached to the real terminal, then takes the screen back and draws everything again.
20///
21/// The program runs on the drawing thread and the application waits for it, which is what the
22/// user expects: they are talking to another program. Background tasks keep running, but nothing
23/// is drawn until the program ends.
24///
25/// On Unix, when the application is the foreground of its controlling terminal, the program
26/// runs in a process group of its own that is made the terminal's foreground until it ends, as
27/// a shell runs a job. The signals of the keys — `Ctrl-C` at a `sudo` prompt, `Ctrl-\` — and
28/// resizes then reach the program and its children only: the application is never ended by
29/// them, and its own signal dispositions are never changed. `Ctrl-Z` does not suspend: a
30/// program that stops is continued at once, since the application offers no way back to it. A
31/// process group is not a session, so the program keeps the controlling terminal and the
32/// session a warm `sudo` ticket is kept for.
33///
34/// ```
35/// use qframe::prelude::*;
36/// use qframe::runtime::{Handoff, HandoffOutcome};
37///
38/// enum Msg {
39///     Authorize,
40///     Authorized(HandoffOutcome),
41/// }
42///
43/// fn update(msg: Msg) -> Command<Msg> {
44///     match msg {
45///         // `sudo -v` asks for the password itself, on the terminal it owns for those seconds.
46///         Msg::Authorize => Command::handoff(
47///             Handoff::new("sudo", Msg::Authorized).arg("-v").notice("Authorizing the installation…"),
48///         ),
49///         Msg::Authorized(_) => Command::none(),
50///     }
51/// }
52/// ```
53pub struct Handoff<Msg> {
54    program: Program,
55    on_finish: Box<dyn FnOnce(HandoffOutcome) -> Msg + Send>,
56}
57
58/// The program a handoff runs and how the screen is left for it; shared by [`Handoff`] and
59/// [`DetachedHandoff`](super::DetachedHandoff).
60#[derive(Debug, Clone)]
61pub(crate) struct Program {
62    pub(crate) program: OsString,
63    pub(crate) args: Vec<OsString>,
64    pub(crate) dir: Option<PathBuf>,
65    pub(crate) env: Vec<(OsString, OsString)>,
66    pub(crate) notice: Option<String>,
67    pub(crate) pause: bool,
68}
69
70impl Program {
71    pub(crate) fn new(program: OsString) -> Self {
72        Self { program, args: Vec::new(), dir: None, env: Vec::new(), notice: None, pause: false }
73    }
74
75    /// The command that starts the program, its standard streams still to be chosen.
76    pub(crate) fn command(&self) -> Child {
77        let mut child = Child::new(&self.program);
78        child.args(&self.args);
79        if let Some(dir) = &self.dir {
80            child.current_dir(dir);
81        }
82        for (key, value) in &self.env {
83            child.env(key, value);
84        }
85        child
86    }
87
88    /// What a test sees of the handoff.
89    pub(crate) fn request(&self) -> HandoffRequest {
90        HandoffRequest {
91            program: self.program.clone(),
92            args: self.args.clone(),
93            notice: self.notice.clone(),
94            pause: self.pause,
95        }
96    }
97}
98
99impl<Msg: Send + 'static> Handoff<Msg> {
100    /// Runs `program`, delivering `on_finish(outcome)` once the application has the screen back.
101    pub fn new(program: impl Into<OsString>, on_finish: impl FnOnce(HandoffOutcome) -> Msg + Send + 'static) -> Self {
102        Self { program: Program::new(program.into()), on_finish: Box::new(on_finish) }
103    }
104
105    /// Adds one argument.
106    #[must_use]
107    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
108        self.program.args.push(arg.into());
109        self
110    }
111
112    /// Adds several arguments, in order.
113    #[must_use]
114    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
115        self.program.args.extend(args.into_iter().map(Into::into));
116        self
117    }
118
119    /// Runs the program in `dir` instead of the application's working directory.
120    #[must_use]
121    pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
122        self.program.dir = Some(dir.into());
123        self
124    }
125
126    /// Sets an environment variable for the program. The rest of the environment is inherited.
127    #[must_use]
128    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
129        self.program.env.push((key.into(), value.into()));
130        self
131    }
132
133    /// A line printed on the cleared screen before the program starts, so the user knows why the
134    /// application stepped aside.
135    #[must_use]
136    pub fn notice(mut self, text: impl Into<String>) -> Self {
137        self.program.notice = Some(text.into());
138        self
139    }
140
141    /// Waits for a key press after the program ends, so its last output can be read. Off by
142    /// default: a program that only takes a moment, such as `sudo -v`, has nothing to read.
143    #[must_use]
144    pub fn pause(mut self, pause: bool) -> Self {
145        self.program.pause = pause;
146        self
147    }
148
149    /// What a test sees of this handoff.
150    pub(crate) fn request(&self) -> HandoffRequest {
151        self.program.request()
152    }
153
154    /// The message of `outcome`, for a harness that never runs the program.
155    pub(crate) fn finish(self, outcome: HandoffOutcome) -> Msg {
156        (self.on_finish)(outcome)
157    }
158
159    /// The same handoff delivering `map(message)` once the application has the screen back.
160    pub(crate) fn map<B>(self, map: impl FnOnce(Msg) -> B + Send + 'static) -> Handoff<B> {
161        let on_finish = self.on_finish;
162        Handoff { program: self.program, on_finish: Box::new(move |outcome| map(on_finish(outcome))) }
163    }
164}
165
166/// How a handoff ended.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum HandoffOutcome {
169    /// The program ran; `code` is `None` when a signal ended it.
170    Finished {
171        /// The exit code, or `None` after a signal such as an interrupt.
172        code: Option<i32>,
173    },
174    /// The program could not be started, or the terminal could not be restored.
175    Failed(String),
176}
177
178/// A handoff a [`Harness`](super::Harness) recorded instead of running.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct HandoffRequest {
181    /// The program asked for.
182    pub program: OsString,
183    /// Its arguments, in order.
184    pub args: Vec<OsString>,
185    /// The line [`Handoff::notice`] would have printed.
186    pub notice: Option<String>,
187    /// Whether [`Handoff::pause`] was turned on.
188    pub pause: bool,
189}
190
191/// What a handoff does to the terminal around the program. The terminal runtime passes the real
192/// screen; tests pass closures that record instead.
193pub(crate) struct HandoffScreen<'a> {
194    /// Leaves application mode, clears the screen and prints the notice, if any. The program
195    /// starts only when this succeeds.
196    pub(crate) release: &'a mut dyn FnMut(Option<&str>) -> io::Result<()>,
197    /// Takes the screen back and draws the whole application again. Runs however the program
198    /// ended, so the terminal is never left behind.
199    pub(crate) take: &'a mut dyn FnMut() -> io::Result<()>,
200    /// Waits for one key press, for [`Handoff::pause`].
201    pub(crate) wait_for_key: &'a mut dyn FnMut() -> io::Result<()>,
202}
203
204/// Runs `handoff` on the calling thread and returns the message of its outcome.
205///
206/// The program inherits the standard streams, so it reads the keyboard itself, and it stays in
207/// this process's session: `sudo` keeps its ticket per controlling terminal, and a program of its
208/// own session would not be given it. Its own process group, which the keys' signals go to, is
209/// arranged by `foreground`.
210pub(crate) fn run<Msg: Send + 'static>(handoff: Handoff<Msg>, screen: &mut HandoffScreen<'_>) -> Msg {
211    let outcome = match (screen.release)(handoff.program.notice.as_deref()) {
212        Ok(()) => {
213            let outcome = spawn(&handoff.program);
214            // The screen is still the program's; waiting here lets its last lines be read.
215            if handoff.program.pause && matches!(outcome, HandoffOutcome::Finished { .. }) {
216                let _ = (screen.wait_for_key)();
217            }
218            match (screen.take)() {
219                Ok(()) => outcome,
220                Err(error) => HandoffOutcome::Failed(error.to_string()),
221            }
222        }
223        Err(error) => {
224            // Application mode may be half gone; taking the screen back puts it right.
225            let _ = (screen.take)();
226            HandoffOutcome::Failed(error.to_string())
227        }
228    };
229    handoff.finish(outcome)
230}
231
232/// Starts the program attached to the terminal and waits for it.
233fn spawn(program: &Program) -> HandoffOutcome {
234    let mut child = program.command();
235    child.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
236    #[cfg(unix)]
237    let status = super::foreground::status(&mut child);
238    #[cfg(not(unix))]
239    let status = child.status();
240    match status {
241        Ok(status) => HandoffOutcome::Finished { code: status.code() },
242        Err(error) => HandoffOutcome::Failed(error.to_string()),
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    /// Runs `handoff` against a stand-in screen and returns its outcome and, in order, what it
251    /// did to that screen. With `release_fails` the screen cannot be left, as one without a
252    /// terminal behind it cannot.
253    fn run_with(handoff: Handoff<HandoffOutcome>, release_fails: bool) -> (HandoffOutcome, Vec<String>) {
254        let steps = std::cell::RefCell::new(Vec::new());
255        let mut release = |notice: Option<&str>| -> io::Result<()> {
256            steps.borrow_mut().push(match notice {
257                Some(text) => format!("release {text}"),
258                None => "release".to_owned(),
259            });
260            if release_fails { Err(io::Error::other("no terminal")) } else { Ok(()) }
261        };
262        let mut take = || -> io::Result<()> {
263            steps.borrow_mut().push("take".to_owned());
264            Ok(())
265        };
266        let mut wait_for_key = || -> io::Result<()> {
267            steps.borrow_mut().push("key".to_owned());
268            Ok(())
269        };
270        let outcome = run(
271            handoff,
272            &mut HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key },
273        );
274        (outcome, steps.into_inner())
275    }
276
277    /// A handoff whose message is the outcome itself, running `script` through `sh`.
278    fn shell(script: &str) -> Handoff<HandoffOutcome> {
279        Handoff::new("sh", |outcome| outcome).arg("-c").arg(script)
280    }
281
282    #[test]
283    fn the_screen_is_released_around_the_program_and_taken_back() {
284        let (outcome, steps) = run_with(shell("exit 0").notice("Installing packages…"), false);
285        assert_eq!(outcome, HandoffOutcome::Finished { code: Some(0) });
286        assert_eq!(steps, ["release Installing packages…", "take"], "the program ran while the screen was released");
287    }
288
289    #[test]
290    fn the_exit_code_reaches_the_message() {
291        let (zero, _) = run_with(shell("exit 0"), false);
292        assert_eq!(zero, HandoffOutcome::Finished { code: Some(0) });
293        let (seven, _) = run_with(shell("exit 7"), false);
294        assert_eq!(seven, HandoffOutcome::Finished { code: Some(7) });
295        // A signal leaves no exit code, so an interrupted program is `None` rather than a number.
296        let (signal, _) = run_with(shell("kill -TERM $$"), false);
297        assert_eq!(signal, HandoffOutcome::Finished { code: None });
298    }
299
300    #[test]
301    fn arguments_the_directory_and_the_environment_reach_the_program() {
302        let (outcome, _) = run_with(
303            Handoff::new("sh", |outcome| outcome)
304                .args(["-c", r#"test "$(pwd)" = / && test "$QUVYTA_HANDOFF_TEST" = ok"#])
305                .dir("/")
306                .env("QUVYTA_HANDOFF_TEST", "ok"),
307            false,
308        );
309        assert_eq!(outcome, HandoffOutcome::Finished { code: Some(0) });
310    }
311
312    #[test]
313    fn an_unstartable_program_fails_and_the_screen_still_comes_back() {
314        let handoff = Handoff::new("quvyta-no-such-program", |outcome| outcome);
315        let (outcome, steps) = run_with(handoff, false);
316        let HandoffOutcome::Failed(reason) = outcome else {
317            panic!("a program that is not there cannot have finished: {outcome:?}");
318        };
319        assert!(!reason.is_empty(), "the reason names what went wrong");
320        assert_eq!(steps, ["release", "take"], "the terminal is taken back even so");
321    }
322
323    #[test]
324    fn a_terminal_that_cannot_be_released_fails_without_running_the_program() {
325        let handoff = shell("exit 0");
326        let (outcome, steps) = run_with(handoff, true);
327        assert_eq!(outcome, HandoffOutcome::Failed("no terminal".to_owned()));
328        assert_eq!(steps, ["release", "take"], "application mode is put back");
329    }
330
331    #[test]
332    fn pause_waits_for_a_key_only_when_it_is_asked_for() {
333        let (_, waited) = run_with(shell("exit 0").pause(true), false);
334        assert_eq!(waited, ["release", "key", "take"], "the key is awaited before the screen is taken back");
335        let (_, quiet) = run_with(shell("exit 0").pause(false), false);
336        assert_eq!(quiet, ["release", "take"]);
337        let (_, missing) = run_with(Handoff::new("quvyta-no-such-program", |o| o).pause(true), false);
338        assert_eq!(missing, ["release", "take"], "a program that never ran leaves nothing to read");
339    }
340
341    #[test]
342    fn the_request_a_harness_records_carries_the_program_and_its_options() {
343        let handoff = shell("less /etc/hostname").notice("Reading").pause(true);
344        let request = handoff.request();
345        assert_eq!(request.program, OsString::from("sh"));
346        assert_eq!(request.args, ["-c", "less /etc/hostname"].map(OsString::from));
347        assert_eq!(request.notice.as_deref(), Some("Reading"));
348        assert!(request.pause);
349    }
350}