Skip to main content

qframe/runtime/
process.rs

1//! Running a child process and reading its output line by line, for showing in a log view.
2//!
3//! Two modes, and the difference matters:
4//!
5//! - **Pipes** (the default) keep standard output and standard error apart, so a failure stays
6//!   recognisable as a failure. Programs that check for a terminal drop their progress bar and
7//!   their colour when they write to a pipe.
8//! - **A pseudo-terminal** ([`Process::pty`]) gives the child a terminal of the size we choose,
9//!   so it draws its progress. Both of its streams land on that one terminal, so every line
10//!   arrives as [`Line::Out`].
11//!
12//! A line that a `\r` overwrites, such as each frame of a progress bar, is dropped as a screen
13//! would drop it, unless the frames are asked for with [`Process::run_with_overwritten`].
14
15use std::ffi::OsString;
16use std::io::{self, Read};
17use std::path::PathBuf;
18use std::process::{Child, Command, Stdio};
19use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
20use std::time::Duration;
21
22/// How long the loop waits for the next line before it looks at `cancel` again.
23const POLL: Duration = Duration::from_millis(10);
24
25/// How much is read from a stream at a time.
26pub(super) const CHUNK: usize = 4096;
27
28/// The longest line held at once. A program that writes more without a newline has its line
29/// delivered in pieces of at most this many bytes, so the reader never holds all of it.
30const MAX_LINE: usize = 64 * 1024;
31
32/// How many lines wait for `on_line` at most. Beyond that the readers stop reading, the pipe
33/// fills and the child waits, so a child that writes faster than the application reads does not
34/// pile its output up in memory.
35const QUEUE: usize = 1024;
36
37/// A child process whose output is read line by line, for showing in a log view.
38///
39/// ```no_run
40/// use qframe::runtime::{Line, Process};
41///
42/// let mut lines = Vec::new();
43/// let outcome = Process::new("sh")
44///     .args(["-c", "echo ready"])
45///     .env("LC_ALL", "C")
46///     .run(&|| false, &mut |line| lines.push(line))?;
47/// assert_eq!(lines, vec![Line::Out("ready".to_owned())]);
48/// # Ok::<(), std::io::Error>(())
49/// ```
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Process {
52    program: OsString,
53    args: Vec<OsString>,
54    dir: Option<PathBuf>,
55    env: Vec<(OsString, OsString)>,
56    pty: Option<(u16, u16)>,
57    no_stdin: bool,
58}
59
60/// Where a line came from. With a pseudo-terminal both streams share one line, so only
61/// [`Line::Out`] appears.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Line {
64    /// A line the child wrote to its standard output.
65    Out(String),
66    /// A line the child wrote to its standard error.
67    Err(String),
68}
69
70/// How the child ended.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum ProcessOutcome {
73    /// The child ran to its end; `code` is `None` when a signal ended it.
74    Finished {
75        /// The exit code, or `None` when a signal ended the child.
76        code: Option<i32>,
77    },
78    /// `cancel` turned true, so the child was killed and its pending output dropped.
79    Cancelled,
80}
81
82impl Process {
83    /// A child process that runs `program`, with pipes and the application's own environment.
84    #[must_use]
85    pub fn new(program: impl Into<OsString>) -> Self {
86        Self { program: program.into(), args: Vec::new(), dir: None, env: Vec::new(), pty: None, no_stdin: false }
87    }
88
89    /// Adds one argument.
90    #[must_use]
91    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
92        self.args.push(arg.into());
93        self
94    }
95
96    /// Adds several arguments, in order.
97    #[must_use]
98    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
99        self.args.extend(args.into_iter().map(Into::into));
100        self
101    }
102
103    /// Runs the child in `dir` instead of the application's working directory.
104    #[must_use]
105    pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
106        self.dir = Some(dir.into());
107        self
108    }
109
110    /// Sets one environment variable for the child. The rest of the environment is inherited,
111    /// and setting a variable the application already has replaces it for the child only.
112    #[must_use]
113    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
114        self.env.push((key.into(), value.into()));
115        self
116    }
117
118    /// Runs the child on a pseudo-terminal `cols` wide and `rows` tall, so programs that check
119    /// for a terminal draw their progress and colour. Standard input stays the application's
120    /// own unless [`Process::no_stdin`] is asked for, and the child keeps the controlling
121    /// terminal, which is what keeps a warm `sudo` ticket shared. Without this the child gets
122    /// pipes and sees no terminal.
123    ///
124    /// The child reads the size given here, not the real terminal's, so its progress bar fits
125    /// the space the application is going to draw it in.
126    #[must_use]
127    pub fn pty(mut self, cols: u16, rows: u16) -> Self {
128        self.pty = Some((cols, rows));
129        self
130    }
131
132    /// Gives the child no standard input: it reads an empty stream (`/dev/null`) instead of the
133    /// application's terminal. A program that asks a question then gets no answer rather than
134    /// the keys meant for the application, which it would otherwise take from under it.
135    ///
136    /// On Unix the child also starts in a process group of its own, so cancelling ends the
137    /// programs it started as well; see [`Process::run`]. It keeps the application's session
138    /// and controlling terminal, so a warm `sudo` ticket still applies. A program that reads
139    /// the terminal itself anyway, as `sudo` does to ask for a password, is stopped by the
140    /// system until it is cancelled, because its group is not the one the terminal belongs to:
141    /// warm the ticket first with a [`Handoff`](crate::runtime::Handoff) of `sudo -v`, or pass
142    /// `sudo -n` so it fails at once instead of asking.
143    #[must_use]
144    pub fn no_stdin(mut self) -> Self {
145        self.no_stdin = true;
146        self
147    }
148
149    /// Runs the child, handing every line to `on_line`, and returns how it ended.
150    ///
151    /// Lines arrive one by one, without their newline. A `\r` overwrites the line being built
152    /// rather than starting a new one, which is how progress bars are written, and the last
153    /// line is delivered even when the output does not end with a newline. Bytes that are not
154    /// UTF-8 become the replacement character instead of being dropped. A line longer than
155    /// 64 KiB is delivered in pieces of at most that size, cut between characters, so a program
156    /// that never writes a newline cannot make the reader hold all of its output.
157    ///
158    /// When the child writes faster than `on_line` takes its lines, the reading waits and the
159    /// child waits with it, rather than its output piling up in memory.
160    ///
161    /// `cancel` is asked between lines, and every few milliseconds while there is none, also
162    /// after the child has closed its output but keeps running; when it turns true the child is
163    /// killed, its pending output is dropped and the outcome is [`ProcessOutcome::Cancelled`].
164    ///
165    /// What cancelling kills depends on standard input. With [`Process::no_stdin`] on Unix, the
166    /// child runs in a process group of its own and the whole group is killed, so the programs
167    /// it started go with it (`podman` with `buildah` and the build's steps), except those that
168    /// moved to a group or session of their own. Without it the child shares the application's
169    /// standard input, which is the terminal: in a group of its own it would be stopped by the
170    /// system the first time it read from it, so it stays in the application's group and only
171    /// the child itself is killed; a program that started children of its own can leave them
172    /// running.
173    ///
174    /// Meant to be called inside a [`Task`](crate::runtime::Task), with `cancel` reading
175    /// [`TaskCx::is_cancelled`](crate::runtime::TaskCx::is_cancelled).
176    ///
177    /// # Errors
178    ///
179    /// Returns an I/O error when the child cannot be started, when a pseudo-terminal was asked
180    /// for and cannot be opened, or when a reading thread cannot be started.
181    pub fn run(self, cancel: &dyn Fn() -> bool, on_line: &mut dyn FnMut(Line)) -> io::Result<ProcessOutcome> {
182        self.run_inner(cancel, on_line, None)
183    }
184
185    /// Runs the child like [`Process::run`], and also hands every line a `\r` overwrites to
186    /// `on_overwritten` instead of dropping it: the frames of a progress bar, as `cargo`,
187    /// `pacman`, `curl` and `git` write them.
188    ///
189    /// A frame is the text built since the last line end or `\r`, delivered when the byte
190    /// after the `\r` shows that the line really is overwritten; `\r\n` and `\r\r\n` stay
191    /// plain line ends and give no frame, and an empty frame is not delivered. When the output
192    /// ends right after a `\r`, its last frame is delivered too. Colour codes and erase codes
193    /// such as `ESC [K` are passed on untouched. A frame comes tagged like a line: [`Line::Out`]
194    /// or [`Line::Err`] for the stream it was written to, and always [`Line::Out`] on a
195    /// pseudo-terminal. Lines and frames arrive in the order the child wrote them; `on_line`
196    /// receives exactly what [`Process::run`] would hand it.
197    ///
198    /// ```no_run
199    /// use qframe::runtime::{Line, Process};
200    ///
201    /// let (mut lines, mut frames) = (Vec::new(), Vec::new());
202    /// Process::new("sh").args(["-c", r"printf '10%\r50%\rdone\n'"]).run_with_overwritten(
203    ///     &|| false,
204    ///     &mut |line| lines.push(line),
205    ///     &mut |frame| frames.push(frame),
206    /// )?;
207    /// assert_eq!(lines, vec![Line::Out("done".to_owned())]);
208    /// assert_eq!(frames, vec![Line::Out("10%".to_owned()), Line::Out("50%".to_owned())]);
209    /// # Ok::<(), std::io::Error>(())
210    /// ```
211    ///
212    /// # Errors
213    ///
214    /// The same as [`Process::run`].
215    pub fn run_with_overwritten(
216        self,
217        cancel: &dyn Fn() -> bool,
218        on_line: &mut dyn FnMut(Line),
219        on_overwritten: &mut dyn FnMut(Line),
220    ) -> io::Result<ProcessOutcome> {
221        self.run_inner(cancel, on_line, Some(on_overwritten))
222    }
223
224    /// Runs the child; frames are read at all only when someone takes them, so a plain run
225    /// never queues them.
226    fn run_inner(
227        self,
228        cancel: &dyn Fn() -> bool,
229        on_line: &mut dyn FnMut(Line),
230        mut on_overwritten: Option<&mut dyn FnMut(Line)>,
231    ) -> io::Result<ProcessOutcome> {
232        let frames = on_overwritten.is_some();
233        let mut command = Command::new(&self.program);
234        command.args(&self.args);
235        // The group is what lets cancelling reach the child's own children; see `run`'s notes.
236        let group = self.no_stdin && cfg!(unix);
237        if self.no_stdin {
238            command.stdin(Stdio::null());
239        } else {
240            command.stdin(Stdio::inherit());
241        }
242        #[cfg(unix)]
243        if group {
244            use std::os::unix::process::CommandExt;
245            command.process_group(0);
246        }
247        if let Some(dir) = &self.dir {
248            command.current_dir(dir);
249        }
250        for (key, value) in &self.env {
251            command.env(key, value);
252        }
253        let (sender, receiver) = mpsc::sync_channel(QUEUE);
254        let mut child = match self.pty {
255            Some(size) => spawn_on_pty(command, size, &sender, frames, group)?,
256            None => spawn_on_pipes(command, &sender, frames, group)?,
257        };
258        // The readers hold the only remaining senders, so the channel ends when they do.
259        drop(sender);
260        loop {
261            if cancel() {
262                kill(&mut child, group);
263                return Ok(ProcessOutcome::Cancelled);
264            }
265            match receiver.recv_timeout(POLL) {
266                Ok(Sent::Line(line)) => on_line(line),
267                Ok(Sent::Overwritten(frame)) => {
268                    if let Some(on_overwritten) = on_overwritten.as_deref_mut() {
269                        on_overwritten(frame);
270                    }
271                }
272                Err(RecvTimeoutError::Timeout) => {}
273                Err(RecvTimeoutError::Disconnected) => break,
274            }
275        }
276        // Its streams are closed, but the child may still be running: it closed them itself, or
277        // they were handed to a program of its own. Waiting keeps asking `cancel`.
278        loop {
279            if let Some(status) = child.try_wait()? {
280                return Ok(ProcessOutcome::Finished { code: status.code() });
281            }
282            if cancel() {
283                kill(&mut child, group);
284                return Ok(ProcessOutcome::Cancelled);
285            }
286            std::thread::sleep(POLL);
287        }
288    }
289}
290
291/// What a reading thread hands to the loop in [`Process::run_inner`]. One channel carries both
292/// so lines and frames keep the order the child wrote them in.
293enum Sent {
294    Line(Line),
295    Overwritten(Line),
296}
297
298/// Kills the child, and with `group` every process still in its process group, and waits for the
299/// child so it leaves nothing behind.
300fn kill(child: &mut Child, group: bool) {
301    #[cfg(unix)]
302    if group {
303        let leader = rustix::process::Pid::from_child(child);
304        // Fails only when nothing is left in the group; the child itself is killed below in any
305        // case.
306        let _ = rustix::process::kill_process_group(leader, rustix::process::Signal::KILL);
307    }
308    #[cfg(not(unix))]
309    let _ = group;
310    let _ = child.kill();
311    let _ = child.wait();
312}
313
314/// Starts the child with a pipe per stream and a reading thread for each, so a failure stays
315/// recognisable as one.
316fn spawn_on_pipes(mut command: Command, sender: &SyncSender<Sent>, frames: bool, group: bool) -> io::Result<Child> {
317    command.stdout(Stdio::piped()).stderr(Stdio::piped());
318    let mut child = command.spawn()?;
319    drop(command);
320    let taken = child.stdout.take().zip(child.stderr.take());
321    let started = match taken {
322        Some((out, err)) => spawn_reader("out", out, Line::Out, frames, sender.clone())
323            .and_then(|()| spawn_reader("err", err, Line::Err, frames, sender.clone())),
324        None => Err(io::Error::other("the child was started without its pipes")),
325    };
326    match started {
327        Ok(()) => Ok(child),
328        Err(error) => {
329            kill(&mut child, group);
330            Err(error)
331        }
332    }
333}
334
335/// Starts the child on a pseudo-terminal of the given size, reading the one stream both of its
336/// streams land on.
337#[cfg(unix)]
338fn spawn_on_pty(
339    mut command: Command,
340    (cols, rows): (u16, u16),
341    sender: &SyncSender<Sent>,
342    frames: bool,
343    group: bool,
344) -> io::Result<Child> {
345    use std::fs::File;
346    use std::os::fd::OwnedFd;
347
348    use rustix::fs::{Mode, OFlags};
349    use rustix::io::{FdFlags, fcntl_setfd};
350    use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt};
351    use rustix::termios::{Winsize, tcsetwinsize};
352
353    // Our own side must not reach the child: it would then hold the terminal open itself and
354    // reading would never end. Where the flag can be given at once, no program another thread
355    // starts in between can inherit it either; elsewhere it is set right after.
356    #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd", target_os = "netbsd"))]
357    let flags = OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC;
358    #[cfg(not(any(target_os = "linux", target_os = "android", target_os = "freebsd", target_os = "netbsd")))]
359    let flags = OpenptFlags::RDWR | OpenptFlags::NOCTTY;
360    let controller = openpt(flags)?;
361    fcntl_setfd(&controller, FdFlags::CLOEXEC)?;
362    grantpt(&controller)?;
363    unlockpt(&controller)?;
364    tcsetwinsize(&controller, Winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 })?;
365    let name = ptsname(&controller, Vec::new())?;
366    // `NOCTTY` leaves the child on the application's controlling terminal instead of making this
367    // pseudo-terminal the controlling one; a `sudo` ticket is held per controlling terminal, so
368    // taking it away would ask for the password again.
369    // `CLOEXEC` keeps this descriptor itself out of the child, which gets the terminal only as
370    // its standard output and error, and out of any program another thread starts meanwhile:
371    // a stray copy would keep the terminal open after the child closed its streams.
372    let device: OwnedFd = rustix::fs::open(name, OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC, Mode::empty())?;
373    command.stdout(Stdio::from(device.try_clone()?)).stderr(Stdio::from(device));
374    let mut child = command.spawn()?;
375    // The command holds the child's side of the terminal until it is dropped, and while it is
376    // open the reading side never reaches its end of file.
377    drop(command);
378    match spawn_reader("pty", File::from(controller), Line::Out, frames, sender.clone()) {
379        Ok(()) => Ok(child),
380        Err(error) => {
381            kill(&mut child, group);
382            Err(error)
383        }
384    }
385}
386
387/// Without Unix there is no pseudo-terminal to open, so the caller is told instead of being
388/// given a child that quietly sees no terminal.
389#[cfg(not(unix))]
390fn spawn_on_pty(
391    _command: Command,
392    _size: (u16, u16),
393    _sender: &SyncSender<Sent>,
394    _frames: bool,
395    _group: bool,
396) -> io::Result<Child> {
397    Err(io::Error::new(io::ErrorKind::Unsupported, "a pseudo-terminal needs a Unix system"))
398}
399
400/// Reads `source` on its own thread, sending one message per line, and with `frames` one per
401/// overwritten frame, until the stream ends or the receiver is gone.
402fn spawn_reader(
403    name: &str,
404    source: impl Read + Send + 'static,
405    tag: fn(String) -> Line,
406    frames: bool,
407    sender: SyncSender<Sent>,
408) -> io::Result<()> {
409    std::thread::Builder::new()
410        .name(format!("quvyta-process-{name}"))
411        .spawn(move || read_lines(source, tag, frames, &sender))
412        .map(|_| ())
413}
414
415/// Sends every line of `source` as a message, stopping as soon as the receiver is gone.
416fn read_lines(mut source: impl Read, tag: fn(String) -> Line, frames: bool, sender: &SyncSender<Sent>) {
417    let mut chunk = [0_u8; CHUNK];
418    let mut lines = Lines::default();
419    // Both closures send; a failed send from either means nobody listens any more.
420    let listening = std::cell::Cell::new(true);
421    let mut on_line = |line| listening.set(listening.get() && sender.send(Sent::Line(tag(line))).is_ok());
422    let mut on_frame = |frame| listening.set(listening.get() && sender.send(Sent::Overwritten(tag(frame))).is_ok());
423    loop {
424        match source.read(&mut chunk) {
425            Ok(0) => break,
426            Ok(count) => {
427                lines.feed_keeping(&chunk[..count], &mut on_line, frames.then_some(&mut on_frame));
428                if !listening.get() {
429                    return;
430                }
431            }
432            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
433            // A pseudo-terminal answers with an I/O error once the child's side is gone, and a
434            // broken pipe says the same thing; both are the end of the stream.
435            Err(_) => break,
436        }
437    }
438    lines.finish_keeping(&mut on_line, frames.then_some(&mut on_frame));
439}
440
441/// Splits a byte stream into lines, letting `\r` overwrite the line being built. What it
442/// overwrites is dropped, or handed to a second callback by [`Lines::feed_keeping`].
443#[derive(Debug, Default)]
444pub(super) struct Lines {
445    buffer: Vec<u8>,
446    /// A `\r` was read and it is not yet known whether a `\n` follows it.
447    pending_return: bool,
448}
449
450impl Lines {
451    /// Feeds `bytes`, calling `emit` once per finished line.
452    pub(super) fn feed(&mut self, bytes: &[u8], emit: &mut impl FnMut(String)) {
453        self.feed_keeping(bytes, emit, None);
454    }
455
456    /// Feeds `bytes` like [`Lines::feed`], also handing each non-empty frame a `\r`
457    /// overwrites to `overwritten` when it is given.
458    pub(super) fn feed_keeping(
459        &mut self,
460        bytes: &[u8],
461        emit: &mut impl FnMut(String),
462        mut overwritten: Option<&mut dyn FnMut(String)>,
463    ) {
464        for &byte in bytes {
465            if self.pending_return {
466                // A terminal ends its lines with `\r\n`, so a `\r` right before a newline ends
467                // the line rather than overwriting it. A second `\r` changes nothing, as on a
468                // screen: a program's own `\r\n` arrives as `\r\r\n` from a pseudo-terminal.
469                match byte {
470                    b'\r' => continue,
471                    b'\n' => {
472                        self.pending_return = false;
473                        emit(self.take());
474                        continue;
475                    }
476                    _ => {
477                        self.pending_return = false;
478                        self.overwrite(&mut overwritten);
479                    }
480                }
481            }
482            match byte {
483                b'\r' => self.pending_return = true,
484                b'\n' => emit(self.take()),
485                _ => {
486                    self.buffer.push(byte);
487                    if self.buffer.len() >= MAX_LINE {
488                        self.emit_piece(emit);
489                    }
490                }
491            }
492        }
493    }
494
495    /// Delivers the full buffer as a line of its own, keeping back the start of a character
496    /// that is not complete yet so no character is cut in two.
497    fn emit_piece(&mut self, emit: &mut impl FnMut(String)) {
498        // A character is at most four bytes, so only the last three can start one that is not
499        // complete yet. Anything else that is not UTF-8 is replaced as usual.
500        let len = self.buffer.len();
501        let mut cut = len;
502        for back in 1..=len.min(3) {
503            let byte = self.buffer[len - back];
504            if byte & 0b1100_0000 != 0b1000_0000 {
505                let width = match byte {
506                    0xc0..=0xdf => 2,
507                    0xe0..=0xef => 3,
508                    0xf0..=0xf7 => 4,
509                    _ => 1,
510                };
511                if width > back {
512                    cut = len - back;
513                }
514                break;
515            }
516        }
517        let rest = self.buffer.split_off(cut);
518        emit(self.take());
519        self.buffer = rest;
520    }
521
522    /// Drops the line a `\r` overwrites, or hands it to `overwritten` when there is one.
523    fn overwrite(&mut self, overwritten: &mut Option<&mut dyn FnMut(String)>) {
524        match overwritten {
525            Some(overwritten) if !self.buffer.is_empty() => overwritten(self.take()),
526            _ => self.buffer.clear(),
527        }
528    }
529
530    /// Delivers the last line when the stream ended without a newline.
531    pub(super) fn finish(&mut self, emit: &mut impl FnMut(String)) {
532        self.finish_keeping(emit, None);
533    }
534
535    /// Ends the stream like [`Lines::finish`]; a last line followed by a `\r` goes to
536    /// `overwritten` when it is given.
537    pub(super) fn finish_keeping(
538        &mut self,
539        emit: &mut impl FnMut(String),
540        mut overwritten: Option<&mut dyn FnMut(String)>,
541    ) {
542        if self.pending_return {
543            // The line was overwritten and nothing was written in its place.
544            self.overwrite(&mut overwritten);
545            self.pending_return = false;
546        }
547        if !self.buffer.is_empty() {
548            emit(self.take());
549        }
550    }
551
552    /// The line built so far, with anything that is not UTF-8 replaced rather than dropped.
553    fn take(&mut self) -> String {
554        let line = String::from_utf8_lossy(&self.buffer).into_owned();
555        self.buffer.clear();
556        line
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use std::sync::atomic::{AtomicUsize, Ordering};
563
564    use super::{Line, Lines, MAX_LINE, Process, ProcessOutcome};
565
566    /// Runs a shell command to its end and returns its lines and outcome.
567    fn shell(script: &str) -> (Vec<Line>, ProcessOutcome) {
568        run(Process::new("sh").args(["-c", script]))
569    }
570
571    /// Runs `process` to its end, never cancelling.
572    fn run(process: Process) -> (Vec<Line>, ProcessOutcome) {
573        let mut lines = Vec::new();
574        let outcome = process.run(&|| false, &mut |line| lines.push(line)).expect("the shell starts");
575        (lines, outcome)
576    }
577
578    #[test]
579    fn keeps_the_two_streams_apart_and_reports_the_exit_code() {
580        let (lines, outcome) = shell("echo bir; echo iki >&2; exit 3");
581        assert_eq!(lines.len(), 2, "{lines:?}");
582        assert!(lines.contains(&Line::Out("bir".to_owned())), "{lines:?}");
583        assert!(lines.contains(&Line::Err("iki".to_owned())), "{lines:?}");
584        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(3) });
585    }
586
587    #[test]
588    fn delivers_the_last_line_without_a_newline() {
589        let (lines, outcome) = shell("printf 'son satir'");
590        assert_eq!(lines, vec![Line::Out("son satir".to_owned())]);
591        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
592    }
593
594    #[test]
595    fn carriage_returns_collapse_into_one_line() {
596        let (lines, _) = shell(r"printf 'a\rbb\rccc\n'");
597        assert_eq!(lines, vec![Line::Out("ccc".to_owned())]);
598    }
599
600    #[test]
601    fn invalid_utf8_becomes_the_replacement_character() {
602        let (lines, _) = shell(r"printf 'a\377b\n'");
603        assert_eq!(lines, vec![Line::Out("a\u{fffd}b".to_owned())]);
604    }
605
606    #[test]
607    fn the_environment_is_inherited_and_one_variable_can_be_replaced() {
608        let (lines, _) = shell("echo ${PATH:+inherited}");
609        assert_eq!(lines, vec![Line::Out("inherited".to_owned())]);
610        let (lines, _) = run(Process::new("sh").args(["-c", "echo $LC_ALL"]).env("LC_ALL", "C"));
611        assert_eq!(lines, vec![Line::Out("C".to_owned())]);
612    }
613
614    #[test]
615    fn runs_in_the_directory_it_is_given() {
616        let (lines, _) = run(Process::new("sh").args(["-c", "pwd"]).dir("/"));
617        assert_eq!(lines, vec![Line::Out("/".to_owned())]);
618    }
619
620    #[test]
621    fn cancelling_kills_a_long_running_child() {
622        let seen = AtomicUsize::new(0);
623        let outcome = Process::new("sh")
624            .args(["-c", "while true; do echo tik; sleep 0.05; done"])
625            .run(&|| seen.load(Ordering::Relaxed) > 0, &mut |line| {
626                assert_eq!(line, Line::Out("tik".to_owned()));
627                seen.fetch_add(1, Ordering::Relaxed);
628            })
629            .expect("the shell starts");
630        assert_eq!(outcome, ProcessOutcome::Cancelled);
631        assert!(seen.load(Ordering::Relaxed) > 0);
632    }
633
634    #[test]
635    fn a_missing_program_is_an_error_and_not_a_panic() {
636        let error = Process::new("quvyta-no-such-program")
637            .run(&|| false, &mut |_| unreachable!("a missing program writes nothing"))
638            .expect_err("a missing program cannot run");
639        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
640    }
641
642    #[cfg(unix)]
643    #[test]
644    fn on_a_pseudo_terminal_the_child_sees_a_terminal_of_the_size_we_gave() {
645        // `stty` reads its standard input, which is the application's own; reading the size the
646        // child was given means asking about the stream it writes to.
647        let (lines, outcome) = run(Process::new("sh").args(["-c", "test -t 1 && stty size <&1"]).pty(100, 24));
648        assert_eq!(lines, vec![Line::Out("24 100".to_owned())]);
649        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
650    }
651
652    #[cfg(unix)]
653    #[test]
654    fn on_a_pseudo_terminal_both_streams_arrive_as_output() {
655        let (lines, outcome) = run(Process::new("sh").args(["-c", "echo bir; echo iki >&2"]).pty(80, 24));
656        assert_eq!(lines, vec![Line::Out("bir".to_owned()), Line::Out("iki".to_owned())]);
657        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
658    }
659
660    /// The process group, session and controlling terminal in a line of `/proc/<pid>/stat`.
661    #[cfg(target_os = "linux")]
662    fn stat_ids(stat: &str) -> [String; 3] {
663        // The command name may hold spaces; after its closing parenthesis come state, parent,
664        // group, session and terminal.
665        let fields: Vec<&str> = stat[stat.rfind(')').expect("name") + 2..].split(' ').collect();
666        [fields[2], fields[3], fields[4]].map(str::to_owned)
667    }
668
669    #[cfg(target_os = "linux")]
670    #[test]
671    fn without_stdin_the_child_reads_an_empty_stream() {
672        let script = r#"readlink /proc/$$/fd/0; read answer; echo "read $?""#;
673        for process in [Process::new("sh").args(["-c", script]), Process::new("sh").args(["-c", script]).pty(80, 24)] {
674            let (lines, outcome) = run(process.no_stdin());
675            assert_eq!(lines, vec![Line::Out("/dev/null".to_owned()), Line::Out("read 1".to_owned())]);
676            assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
677        }
678    }
679
680    #[cfg(target_os = "linux")]
681    #[test]
682    fn only_a_child_without_stdin_gets_a_group_of_its_own_and_it_keeps_the_session() {
683        let script = "cat /proc/$$/stat";
684        let ours = stat_ids(&std::fs::read_to_string("/proc/self/stat").expect("stat"));
685        let ids = |process: Process| {
686            let (lines, _) = run(process);
687            let [Line::Out(stat)] = &lines[..] else { panic!("one line: {lines:?}") };
688            stat_ids(stat)
689        };
690        let shared = ids(Process::new("sh").args(["-c", script]));
691        assert_eq!(shared, ours, "a child reading the terminal stays in the application's group");
692        for process in [Process::new("sh").args(["-c", script]), Process::new("sh").args(["-c", script]).pty(80, 24)] {
693            let [group, session, terminal] = ids(process.no_stdin());
694            assert_ne!(group, ours[0], "a group of its own");
695            // `sudo` keeps its ticket per controlling terminal and session, so both must stay.
696            assert_eq!(session, ours[1], "the application's session");
697            assert_eq!(terminal, ours[2], "the application's controlling terminal");
698        }
699    }
700
701    /// Whether `pid` has ended: gone, or ended and waiting for its parent to collect it.
702    #[cfg(target_os = "linux")]
703    fn ended(pid: &str) -> bool {
704        std::fs::read_to_string(format!("/proc/{pid}/stat"))
705            .map_or(true, |stat| stat[stat.rfind(')').expect("name") + 2..].starts_with('Z'))
706    }
707
708    #[cfg(target_os = "linux")]
709    #[test]
710    fn cancelling_a_child_without_stdin_ends_the_programs_it_started() {
711        for pty in [false, true] {
712            let seen = std::cell::RefCell::new(Vec::new());
713            let process = Process::new("sh").args(["-c", "sleep 60 & echo $!; sleep 60 & echo $!; wait"]).no_stdin();
714            let process = if pty { process.pty(80, 24) } else { process };
715            let outcome = process
716                .run(&|| seen.borrow().len() == 2, &mut |line| match line {
717                    Line::Out(pid) => seen.borrow_mut().push(pid),
718                    Line::Err(text) => panic!("nothing on standard error: {text}"),
719                })
720                .expect("the shell starts");
721            assert_eq!(outcome, ProcessOutcome::Cancelled);
722            let pids = seen.into_inner();
723            let started = std::time::Instant::now();
724            while !pids.iter().all(|pid| ended(pid)) {
725                assert!(started.elapsed() < std::time::Duration::from_secs(20), "still running: {pids:?} (pty {pty})");
726                std::thread::sleep(std::time::Duration::from_millis(20));
727            }
728        }
729    }
730
731    #[cfg(target_os = "linux")]
732    #[test]
733    fn cancelling_a_child_that_shares_stdin_ends_only_the_child() {
734        // Documented: without `no_stdin` the child stays in the application's group, and its
735        // own children outlive it. They are ended here by hand so the test leaves nothing behind.
736        let seen = std::cell::RefCell::new(Vec::new());
737        let outcome = Process::new("sh")
738            .args(["-c", "sleep 60 & echo $!; wait"])
739            .run(&|| seen.borrow().len() == 1, &mut |line| {
740                if let Line::Out(pid) = line {
741                    seen.borrow_mut().push(pid);
742                }
743            })
744            .expect("the shell starts");
745        assert_eq!(outcome, ProcessOutcome::Cancelled);
746        let pid = seen.into_inner().remove(0);
747        std::thread::sleep(std::time::Duration::from_millis(200));
748        let survived = !ended(&pid);
749        let raw: i32 = pid.parse().expect("a process id");
750        if let Some(pid) = rustix::process::Pid::from_raw(raw) {
751            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
752        }
753        assert!(survived, "the grandchild outlives a cancel of the child");
754    }
755
756    #[test]
757    fn a_line_ended_twice_by_a_return_is_kept() {
758        // A program that ends its own lines with `\r\n` writes `\r\r\n` on a pseudo-terminal,
759        // which turns every `\n` into `\r\n`. The line is on the screen, so it is not lost.
760        let mut lines = Lines::default();
761        let mut seen = Vec::new();
762        lines.feed(b"hazir\r\r\nbitti\r\r\r\n", &mut |line| seen.push(line));
763        assert_eq!(seen, vec!["hazir".to_owned(), "bitti".to_owned()]);
764    }
765
766    #[cfg(unix)]
767    #[test]
768    fn a_pseudo_terminal_line_ended_by_the_program_itself_arrives_whole() {
769        let (lines, _) = run(Process::new("sh").args(["-c", r"printf 'bir\r\niki\r\n'"]).pty(80, 24));
770        assert_eq!(lines, vec![Line::Out("bir".to_owned()), Line::Out("iki".to_owned())]);
771    }
772
773    #[test]
774    fn a_line_without_an_end_is_delivered_in_pieces_of_bounded_size() {
775        // A program that never writes a newline must not make the reader hold all of it.
776        let (lines, _) = shell("head -c 300000 /dev/zero | tr '\\0' a");
777        let total: usize = lines
778            .iter()
779            .map(|line| match line {
780                Line::Out(text) => {
781                    assert!(text.len() <= MAX_LINE, "a piece of {} bytes", text.len());
782                    assert!(text.bytes().all(|byte| byte == b'a'));
783                    text.len()
784                }
785                Line::Err(text) => panic!("nothing was written to standard error: {text}"),
786            })
787            .sum();
788        assert_eq!(total, 300_000, "nothing is lost between the pieces");
789    }
790
791    #[test]
792    fn a_long_line_is_never_cut_inside_a_character() {
793        let mut lines = Lines::default();
794        let mut seen = Vec::new();
795        // One byte of padding, so the two-byte `ç` straddles every piece boundary.
796        let mut text = vec![b'a'];
797        for _ in 0..MAX_LINE {
798            text.extend_from_slice("ç".as_bytes());
799        }
800        lines.feed(&text, &mut |line| seen.push(line));
801        lines.finish(&mut |line| seen.push(line));
802        assert!(seen.len() > 1, "the line was split");
803        assert!(seen.iter().all(|line| !line.contains('\u{fffd}')), "no character was cut in two");
804        assert_eq!(seen.concat().as_bytes(), text.as_slice());
805    }
806
807    #[test]
808    fn a_child_that_closes_its_output_can_still_be_cancelled() {
809        // Its streams end at once, but it keeps running; cancelling must still stop it.
810        let started = std::time::Instant::now();
811        let outcome = Process::new("sh")
812            .args(["-c", "exec >&- 2>&-; sleep 20"])
813            .run(&|| started.elapsed() > std::time::Duration::from_millis(200), &mut |_| {})
814            .expect("the shell starts");
815        assert_eq!(outcome, ProcessOutcome::Cancelled);
816        assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
817    }
818
819    #[test]
820    fn a_flood_of_output_waits_for_the_reader_instead_of_piling_up() {
821        let dir = std::env::temp_dir().join(format!("quvyta-process-flood-{}", std::process::id()));
822        let _ = std::fs::remove_dir_all(&dir);
823        std::fs::create_dir_all(&dir).expect("test directory");
824        let marker = dir.join("done");
825        let script = format!("yes | head -n 200000; touch '{}'", marker.display());
826        let mut first = true;
827        let mut finished_while_the_reader_slept = false;
828        let mut count = 0_usize;
829        let outcome = Process::new("sh")
830            .args(["-c", &script])
831            .run(&|| false, &mut |_| {
832                count += 1;
833                if first {
834                    first = false;
835                    // Far longer than writing 200000 short lines takes when nothing holds it back.
836                    std::thread::sleep(std::time::Duration::from_millis(700));
837                    finished_while_the_reader_slept = marker.exists();
838                }
839            })
840            .expect("the shell starts");
841        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
842        assert_eq!(count, 200_000);
843        assert!(!finished_while_the_reader_slept, "the child wrote everything into memory while nobody read");
844        std::fs::remove_dir_all(&dir).expect("clean");
845    }
846
847    #[cfg(target_os = "linux")]
848    #[test]
849    fn the_child_on_a_pseudo_terminal_holds_it_only_on_its_own_streams() {
850        // Any other descriptor of the terminal would outlive the streams in the child and in the
851        // programs it starts, and keep the reader waiting after they are closed.
852        let script = r#"t=$(readlink /proc/$$/fd/1); n=0; for f in /proc/$$/fd/*; do [ "$(readlink "$f")" = "$t" ] && n=$((n+1)); done; echo $n"#;
853        let (lines, _) = run(Process::new("sh").args(["-c", script]).pty(80, 24));
854        assert_eq!(lines, vec![Line::Out("2".to_owned())], "standard output and standard error, nothing else");
855    }
856
857    #[test]
858    fn a_line_split_across_reads_stays_one_line() {
859        let mut lines = Lines::default();
860        let mut seen = Vec::new();
861        let mut emit = |line: String| seen.push(line);
862        lines.feed(b"ilk par", &mut emit);
863        lines.feed(b"\xc3", &mut emit);
864        lines.feed(b"\xa7a\r\nson", &mut emit);
865        lines.finish(&mut emit);
866        assert_eq!(seen, vec!["ilk parça".to_owned(), "son".to_owned()]);
867    }
868
869    /// Feeds `bytes` in one go and ends the stream, returning the lines and the overwritten
870    /// frames.
871    fn split_keeping_frames(bytes: &[u8]) -> (Vec<String>, Vec<String>) {
872        let mut lines = Lines::default();
873        let (mut seen, mut frames) = (Vec::new(), Vec::new());
874        lines.feed_keeping(bytes, &mut |line| seen.push(line), Some(&mut |frame| frames.push(frame)));
875        lines.finish_keeping(&mut |line| seen.push(line), Some(&mut |frame| frames.push(frame)));
876        (seen, frames)
877    }
878
879    #[test]
880    fn frames_overwritten_by_a_return_are_kept_only_when_asked_for() {
881        let (seen, frames) = split_keeping_frames(b"bir\riki\ruc\rbitti\r\n");
882        assert_eq!(seen, vec!["bitti".to_owned()]);
883        assert_eq!(frames, vec!["bir".to_owned(), "iki".to_owned(), "uc".to_owned()]);
884        let mut lines = Lines::default();
885        let mut seen = Vec::new();
886        lines.feed(b"bir\riki\ruc\rbitti\r\n", &mut |line| seen.push(line));
887        lines.finish(&mut |line| seen.push(line));
888        assert_eq!(seen, vec!["bitti".to_owned()]);
889    }
890
891    #[test]
892    fn a_line_ended_by_returns_and_a_newline_is_no_frame() {
893        let (seen, frames) = split_keeping_frames(b"hazir\r\r\nbitti\r\n\rbos\r\r\r\n");
894        assert_eq!(seen, vec!["hazir".to_owned(), "bitti".to_owned(), "bos".to_owned()]);
895        assert!(frames.is_empty(), "{frames:?}");
896    }
897
898    #[test]
899    fn a_stream_ending_in_a_return_delivers_its_last_frame() {
900        let (seen, frames) = split_keeping_frames(b"once\r10%\r20%\r");
901        assert!(seen.is_empty(), "{seen:?}");
902        assert_eq!(frames, vec!["once".to_owned(), "10%".to_owned(), "20%".to_owned()]);
903        // A return split from what follows it by a read still waits for that byte.
904        let mut lines = Lines::default();
905        let (mut seen, mut frames) = (Vec::new(), Vec::new());
906        lines.feed_keeping(b"30%\r", &mut |line| seen.push(line), Some(&mut |frame| frames.push(frame)));
907        assert!(frames.is_empty(), "a return before a newline is not yet known to overwrite");
908        lines.feed_keeping(b"\n", &mut |line| seen.push(line), Some(&mut |frame| frames.push(frame)));
909        assert_eq!((seen, frames), (vec!["30%".to_owned()], Vec::new()));
910    }
911
912    #[test]
913    fn frames_keep_their_colour_and_erase_codes() {
914        let (seen, frames) = split_keeping_frames(b"\x1b[1mFetch\x1b[0m 1\r\x1b[K\x1b[92mDone\x1b[0m\r\n");
915        assert_eq!(frames, vec!["\x1b[1mFetch\x1b[0m 1".to_owned()]);
916        assert_eq!(seen, vec!["\x1b[K\x1b[92mDone\x1b[0m".to_owned()]);
917    }
918
919    #[test]
920    fn every_frame_of_a_recorded_cargo_install_is_kept() {
921        let recorded = include_bytes!("../../tests/fixtures/cargo-install-pty.txt");
922        let (seen, frames) = split_keeping_frames(recorded);
923        assert_eq!(frames.len(), 159, "every overwritten frame");
924        assert_eq!(seen.len(), 76, "the lines themselves are unchanged");
925        let building: Vec<&String> = frames.iter().filter(|frame| frame.contains("Building")).collect();
926        assert_eq!(building.len(), 51);
927        assert!(building[0].contains("] 0/46: anstyle"), "{:?}", building[0]);
928        assert!(building.iter().any(|frame| frame.contains("] 45/46: hexyl")), "{building:?}");
929        // Without asking, the same bytes give the same lines and no frame reaches anyone.
930        let mut lines = Lines::default();
931        let mut plain = Vec::new();
932        lines.feed(recorded, &mut |line| plain.push(line));
933        lines.finish(&mut |line| plain.push(line));
934        assert_eq!(plain, seen);
935    }
936
937    /// Runs `process` to its end asking for overwritten frames, returning the lines and frames in
938    /// the order they arrived.
939    fn run_keeping_frames(process: Process) -> Vec<(bool, Line)> {
940        let seen = std::cell::RefCell::new(Vec::new());
941        process
942            .run_with_overwritten(&|| false, &mut |line| seen.borrow_mut().push((false, line)), &mut |frame| {
943                seen.borrow_mut().push((true, frame));
944            })
945            .expect("the shell starts");
946        seen.into_inner()
947    }
948
949    #[test]
950    fn overwritten_frames_arrive_through_a_pipe_in_order_and_tagged_by_stream() {
951        let seen = run_keeping_frames(Process::new("sh").args(["-c", r"printf 'a\rb\rc\n'; printf '1%%\r2%%\r' >&2"]));
952        let out: Vec<_> = seen.iter().filter(|(_, line)| matches!(line, Line::Out(_))).cloned().collect();
953        let err: Vec<_> = seen.iter().filter(|(_, line)| matches!(line, Line::Err(_))).cloned().collect();
954        assert_eq!(
955            out,
956            vec![
957                (true, Line::Out("a".to_owned())),
958                (true, Line::Out("b".to_owned())),
959                (false, Line::Out("c".to_owned()))
960            ]
961        );
962        assert_eq!(err, vec![(true, Line::Err("1%".to_owned())), (true, Line::Err("2%".to_owned()))]);
963    }
964
965    #[cfg(unix)]
966    #[test]
967    fn overwritten_frames_arrive_from_a_pseudo_terminal() {
968        let seen = run_keeping_frames(Process::new("sh").args(["-c", r"printf 'a\rb\rc\nd\r\n'"]).pty(80, 24));
969        assert_eq!(
970            seen,
971            vec![
972                (true, Line::Out("a".to_owned())),
973                (true, Line::Out("b".to_owned())),
974                (false, Line::Out("c".to_owned())),
975                (false, Line::Out("d".to_owned())),
976            ]
977        );
978    }
979}