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            dir: self.dir.clone(),
94            notice: self.notice.clone(),
95            pause: self.pause,
96        }
97    }
98}
99
100impl<Msg: Send + 'static> Handoff<Msg> {
101    /// Runs `program`, delivering `on_finish(outcome)` once the application has the screen back.
102    pub fn new(program: impl Into<OsString>, on_finish: impl FnOnce(HandoffOutcome) -> Msg + Send + 'static) -> Self {
103        Self { program: Program::new(program.into()), on_finish: Box::new(on_finish) }
104    }
105
106    /// Adds one argument.
107    #[must_use]
108    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
109        self.program.args.push(arg.into());
110        self
111    }
112
113    /// Adds several arguments, in order.
114    #[must_use]
115    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
116        self.program.args.extend(args.into_iter().map(Into::into));
117        self
118    }
119
120    /// Runs the program in `dir` instead of the application's working directory.
121    #[must_use]
122    pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
123        self.program.dir = Some(dir.into());
124        self
125    }
126
127    /// Sets an environment variable for the program. The rest of the environment is inherited.
128    #[must_use]
129    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
130        self.program.env.push((key.into(), value.into()));
131        self
132    }
133
134    /// A line printed on the cleared screen before the program starts, so the user knows why the
135    /// application stepped aside.
136    #[must_use]
137    pub fn notice(mut self, text: impl Into<String>) -> Self {
138        self.program.notice = Some(text.into());
139        self
140    }
141
142    /// Waits for a key press after the program ends, so its last output can be read. Off by
143    /// default: a program that only takes a moment, such as `sudo -v`, has nothing to read.
144    #[must_use]
145    pub fn pause(mut self, pause: bool) -> Self {
146        self.program.pause = pause;
147        self
148    }
149
150    /// What a test sees of this handoff.
151    pub(crate) fn request(&self) -> HandoffRequest {
152        self.program.request()
153    }
154
155    /// The message of `outcome`, for a harness that never runs the program.
156    pub(crate) fn finish(self, outcome: HandoffOutcome) -> Msg {
157        (self.on_finish)(outcome)
158    }
159
160    /// The same handoff delivering `map(message)` once the application has the screen back.
161    pub(crate) fn map<B>(self, map: impl FnOnce(Msg) -> B + Send + 'static) -> Handoff<B> {
162        let on_finish = self.on_finish;
163        Handoff { program: self.program, on_finish: Box::new(move |outcome| map(on_finish(outcome))) }
164    }
165}
166
167/// How a handoff ended.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum HandoffOutcome {
170    /// The program ran; `code` is `None` when a signal ended it.
171    Finished {
172        /// The exit code, or `None` after a signal such as an interrupt.
173        code: Option<i32>,
174    },
175    /// The program could not be started, or the terminal could not be restored.
176    Failed(String),
177}
178
179/// A handoff a [`Harness`](super::Harness) recorded instead of running.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct HandoffRequest {
182    /// The program asked for.
183    pub program: OsString,
184    /// Its arguments, in order.
185    pub args: Vec<OsString>,
186    /// The working folder [`Handoff::dir`] gave; `None` when the program runs in the
187    /// application's own.
188    pub dir: Option<PathBuf>,
189    /// The line [`Handoff::notice`] would have printed.
190    pub notice: Option<String>,
191    /// Whether [`Handoff::pause`] was turned on.
192    pub pause: bool,
193}
194
195/// What a handoff does to the terminal around the program. The terminal runtime passes the real
196/// screen; tests pass closures that record instead.
197pub(crate) struct HandoffScreen<'a> {
198    /// Leaves application mode, clears the screen and prints the notice, if any. The program
199    /// starts only when this succeeds.
200    pub(crate) release: &'a mut dyn FnMut(Option<&str>) -> io::Result<()>,
201    /// Takes the screen back and draws the whole application again. Runs however the program
202    /// ended, so the terminal is never left behind.
203    pub(crate) take: &'a mut dyn FnMut() -> io::Result<()>,
204    /// Waits for one key press, for [`Handoff::pause`].
205    pub(crate) wait_for_key: &'a mut dyn FnMut() -> io::Result<()>,
206}
207
208/// Runs `handoff` on the calling thread and returns the message of its outcome.
209///
210/// The program inherits the standard streams, so it reads the keyboard itself, and it stays in
211/// this process's session: `sudo` keeps its ticket per controlling terminal, and a program of its
212/// own session would not be given it. Its own process group, which the keys' signals go to, is
213/// arranged by `foreground`.
214pub(crate) fn run<Msg: Send + 'static>(handoff: Handoff<Msg>, screen: &mut HandoffScreen<'_>) -> Msg {
215    let outcome = match (screen.release)(handoff.program.notice.as_deref()) {
216        Ok(()) => {
217            let outcome = spawn(&handoff.program);
218            // The screen is still the program's; waiting here lets its last lines be read.
219            if handoff.program.pause && matches!(outcome, HandoffOutcome::Finished { .. }) {
220                let _ = (screen.wait_for_key)();
221            }
222            match (screen.take)() {
223                Ok(()) => outcome,
224                Err(error) => HandoffOutcome::Failed(error.to_string()),
225            }
226        }
227        Err(error) => {
228            // Application mode may be half gone; taking the screen back puts it right.
229            let _ = (screen.take)();
230            HandoffOutcome::Failed(error.to_string())
231        }
232    };
233    handoff.finish(outcome)
234}
235
236/// Starts the program attached to the terminal and waits for it.
237fn spawn(program: &Program) -> HandoffOutcome {
238    let mut child = program.command();
239    child.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
240    #[cfg(unix)]
241    let status = super::foreground::status(&mut child);
242    #[cfg(not(unix))]
243    let status = child.status();
244    match status {
245        Ok(status) => HandoffOutcome::Finished { code: status.code() },
246        Err(error) => HandoffOutcome::Failed(error.to_string()),
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// Runs `handoff` against a stand-in screen and returns its outcome and, in order, what it
255    /// did to that screen. With `release_fails` the screen cannot be left, as one without a
256    /// terminal behind it cannot.
257    fn run_with(handoff: Handoff<HandoffOutcome>, release_fails: bool) -> (HandoffOutcome, Vec<String>) {
258        let steps = std::cell::RefCell::new(Vec::new());
259        let mut release = |notice: Option<&str>| -> io::Result<()> {
260            steps.borrow_mut().push(match notice {
261                Some(text) => format!("release {text}"),
262                None => "release".to_owned(),
263            });
264            if release_fails { Err(io::Error::other("no terminal")) } else { Ok(()) }
265        };
266        let mut take = || -> io::Result<()> {
267            steps.borrow_mut().push("take".to_owned());
268            Ok(())
269        };
270        let mut wait_for_key = || -> io::Result<()> {
271            steps.borrow_mut().push("key".to_owned());
272            Ok(())
273        };
274        let outcome = run(
275            handoff,
276            &mut HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key },
277        );
278        (outcome, steps.into_inner())
279    }
280
281    /// A handoff whose message is the outcome itself, running `script` through `sh`.
282    fn shell(script: &str) -> Handoff<HandoffOutcome> {
283        Handoff::new("sh", |outcome| outcome).arg("-c").arg(script)
284    }
285
286    #[test]
287    fn the_screen_is_released_around_the_program_and_taken_back() {
288        let (outcome, steps) = run_with(shell("exit 0").notice("Installing packages…"), false);
289        assert_eq!(outcome, HandoffOutcome::Finished { code: Some(0) });
290        assert_eq!(steps, ["release Installing packages…", "take"], "the program ran while the screen was released");
291    }
292
293    #[test]
294    fn the_exit_code_reaches_the_message() {
295        let (zero, _) = run_with(shell("exit 0"), false);
296        assert_eq!(zero, HandoffOutcome::Finished { code: Some(0) });
297        let (seven, _) = run_with(shell("exit 7"), false);
298        assert_eq!(seven, HandoffOutcome::Finished { code: Some(7) });
299        // A signal leaves no exit code, so an interrupted program is `None` rather than a number.
300        let (signal, _) = run_with(shell("kill -TERM $$"), false);
301        assert_eq!(signal, HandoffOutcome::Finished { code: None });
302    }
303
304    #[test]
305    fn arguments_the_directory_and_the_environment_reach_the_program() {
306        let (outcome, _) = run_with(
307            Handoff::new("sh", |outcome| outcome)
308                .args(["-c", r#"test "$(pwd)" = / && test "$QUVYTA_HANDOFF_TEST" = ok"#])
309                .dir("/")
310                .env("QUVYTA_HANDOFF_TEST", "ok"),
311            false,
312        );
313        assert_eq!(outcome, HandoffOutcome::Finished { code: Some(0) });
314    }
315
316    #[test]
317    fn an_unstartable_program_fails_and_the_screen_still_comes_back() {
318        let handoff = Handoff::new("quvyta-no-such-program", |outcome| outcome);
319        let (outcome, steps) = run_with(handoff, false);
320        let HandoffOutcome::Failed(reason) = outcome else {
321            panic!("a program that is not there cannot have finished: {outcome:?}");
322        };
323        assert!(!reason.is_empty(), "the reason names what went wrong");
324        assert_eq!(steps, ["release", "take"], "the terminal is taken back even so");
325    }
326
327    #[test]
328    fn a_terminal_that_cannot_be_released_fails_without_running_the_program() {
329        let handoff = shell("exit 0");
330        let (outcome, steps) = run_with(handoff, true);
331        assert_eq!(outcome, HandoffOutcome::Failed("no terminal".to_owned()));
332        assert_eq!(steps, ["release", "take"], "application mode is put back");
333    }
334
335    #[test]
336    fn pause_waits_for_a_key_only_when_it_is_asked_for() {
337        let (_, waited) = run_with(shell("exit 0").pause(true), false);
338        assert_eq!(waited, ["release", "key", "take"], "the key is awaited before the screen is taken back");
339        let (_, quiet) = run_with(shell("exit 0").pause(false), false);
340        assert_eq!(quiet, ["release", "take"]);
341        let (_, missing) = run_with(Handoff::new("quvyta-no-such-program", |o| o).pause(true), false);
342        assert_eq!(missing, ["release", "take"], "a program that never ran leaves nothing to read");
343    }
344
345    #[test]
346    fn the_request_a_harness_records_carries_the_program_and_its_options() {
347        let handoff = shell("less /etc/hostname").notice("Reading").pause(true);
348        let request = handoff.request();
349        assert_eq!(request.program, OsString::from("sh"));
350        assert_eq!(request.args, ["-c", "less /etc/hostname"].map(OsString::from));
351        assert_eq!(request.notice.as_deref(), Some("Reading"));
352        assert!(request.pause);
353        assert_eq!(request.dir, None, "without `dir` the program runs where the application does");
354        let placed = shell("exit 0").dir("/srv/notes").request();
355        assert_eq!(placed.dir, Some(PathBuf::from("/srv/notes")), "the folder is recorded");
356    }
357}