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