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    // The kill fails only on a child that ended by itself, which is what was wanted, and the
311    // wait collects it rather than being asked anything. This function has no answer to give:
312    // its callers have already decided the child is to go.
313    let _ = child.kill();
314    let _ = child.wait();
315}
316
317/// Starts the child with a pipe per stream and a reading thread for each, so a failure stays
318/// recognisable as one.
319fn spawn_on_pipes(mut command: Command, sender: &SyncSender<Sent>, frames: bool, group: bool) -> io::Result<Child> {
320    command.stdout(Stdio::piped()).stderr(Stdio::piped());
321    let mut child = command.spawn()?;
322    drop(command);
323    let taken = child.stdout.take().zip(child.stderr.take());
324    let started = match taken {
325        Some((out, err)) => spawn_reader("out", out, Line::Out, frames, sender.clone())
326            .and_then(|()| spawn_reader("err", err, Line::Err, frames, sender.clone())),
327        None => Err(io::Error::other("the child was started without its pipes")),
328    };
329    match started {
330        Ok(()) => Ok(child),
331        Err(error) => {
332            kill(&mut child, group);
333            Err(error)
334        }
335    }
336}
337
338/// Starts the child on a pseudo-terminal of the given size, reading the one stream both of its
339/// streams land on.
340#[cfg(unix)]
341fn spawn_on_pty(
342    mut command: Command,
343    (cols, rows): (u16, u16),
344    sender: &SyncSender<Sent>,
345    frames: bool,
346    group: bool,
347) -> io::Result<Child> {
348    use std::fs::File;
349    use std::os::fd::OwnedFd;
350
351    use rustix::fs::{Mode, OFlags};
352    use rustix::io::{FdFlags, fcntl_setfd};
353    use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt};
354    use rustix::termios::{Winsize, tcsetwinsize};
355
356    // Our own side must not reach the child: it would then hold the terminal open itself and
357    // reading would never end. Where the flag can be given at once, no program another thread
358    // starts in between can inherit it either; elsewhere it is set right after.
359    #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd", target_os = "netbsd"))]
360    let flags = OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC;
361    #[cfg(not(any(target_os = "linux", target_os = "android", target_os = "freebsd", target_os = "netbsd")))]
362    let flags = OpenptFlags::RDWR | OpenptFlags::NOCTTY;
363    let controller = openpt(flags)?;
364    fcntl_setfd(&controller, FdFlags::CLOEXEC)?;
365    grantpt(&controller)?;
366    unlockpt(&controller)?;
367    tcsetwinsize(&controller, Winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 })?;
368    let name = ptsname(&controller, Vec::new())?;
369    // `NOCTTY` leaves the child on the application's controlling terminal instead of making this
370    // pseudo-terminal the controlling one; a `sudo` ticket is held per controlling terminal, so
371    // taking it away would ask for the password again.
372    // `CLOEXEC` keeps this descriptor itself out of the child, which gets the terminal only as
373    // its standard output and error, and out of any program another thread starts meanwhile:
374    // a stray copy would keep the terminal open after the child closed its streams.
375    let device: OwnedFd = rustix::fs::open(name, OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC, Mode::empty())?;
376    command.stdout(Stdio::from(device.try_clone()?)).stderr(Stdio::from(device));
377    let mut child = command.spawn()?;
378    // The command holds the child's side of the terminal until it is dropped, and while it is
379    // open the reading side never reaches its end of file.
380    drop(command);
381    match spawn_reader("pty", File::from(controller), Line::Out, frames, sender.clone()) {
382        Ok(()) => Ok(child),
383        Err(error) => {
384            kill(&mut child, group);
385            Err(error)
386        }
387    }
388}
389
390/// Without Unix there is no pseudo-terminal to open, so the caller is told instead of being
391/// given a child that quietly sees no terminal.
392#[cfg(not(unix))]
393fn spawn_on_pty(
394    _command: Command,
395    _size: (u16, u16),
396    _sender: &SyncSender<Sent>,
397    _frames: bool,
398    _group: bool,
399) -> io::Result<Child> {
400    Err(io::Error::new(io::ErrorKind::Unsupported, "a pseudo-terminal needs a Unix system"))
401}
402
403/// Reads `source` on its own thread, sending one message per line, and with `frames` one per
404/// overwritten frame, until the stream ends or the receiver is gone.
405fn spawn_reader(
406    name: &str,
407    source: impl Read + Send + 'static,
408    tag: fn(String) -> Line,
409    frames: bool,
410    sender: SyncSender<Sent>,
411) -> io::Result<()> {
412    std::thread::Builder::new()
413        .name(format!("quvyta-process-{name}"))
414        .spawn(move || read_lines(source, tag, frames, &sender))
415        .map(|_| ())
416}
417
418/// Sends every line of `source` as a message, stopping as soon as the receiver is gone.
419fn read_lines(mut source: impl Read, tag: fn(String) -> Line, frames: bool, sender: &SyncSender<Sent>) {
420    let mut chunk = [0_u8; CHUNK];
421    let mut lines = Lines::default();
422    // Both closures send; a failed send from either means nobody listens any more.
423    let listening = std::cell::Cell::new(true);
424    let mut on_line = |line| listening.set(listening.get() && sender.send(Sent::Line(tag(line))).is_ok());
425    let mut on_frame = |frame| listening.set(listening.get() && sender.send(Sent::Overwritten(tag(frame))).is_ok());
426    loop {
427        match source.read(&mut chunk) {
428            Ok(0) => break,
429            Ok(count) => {
430                lines.feed_keeping(&chunk[..count], &mut on_line, frames.then_some(&mut on_frame));
431                if !listening.get() {
432                    return;
433                }
434            }
435            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
436            // A pseudo-terminal answers with an I/O error once the child's side is gone, and a
437            // broken pipe says the same thing; both are the end of the stream.
438            Err(_) => break,
439        }
440    }
441    lines.finish_keeping(&mut on_line, frames.then_some(&mut on_frame));
442}
443
444/// Splits a byte stream into lines, letting `\r` overwrite the line being built. What it
445/// overwrites is dropped, or handed to a second callback by [`Lines::feed_keeping`].
446#[derive(Debug, Default)]
447pub(super) struct Lines {
448    buffer: Vec<u8>,
449    /// A `\r` was read and it is not yet known whether a `\n` follows it.
450    pending_return: bool,
451}
452
453impl Lines {
454    /// Feeds `bytes`, calling `emit` once per finished line.
455    pub(super) fn feed(&mut self, bytes: &[u8], emit: &mut impl FnMut(String)) {
456        self.feed_keeping(bytes, emit, None);
457    }
458
459    /// Feeds `bytes` like [`Lines::feed`], also handing each non-empty frame a `\r`
460    /// overwrites to `overwritten` when it is given.
461    pub(super) fn feed_keeping(
462        &mut self,
463        bytes: &[u8],
464        emit: &mut impl FnMut(String),
465        mut overwritten: Option<&mut dyn FnMut(String)>,
466    ) {
467        for &byte in bytes {
468            if self.pending_return {
469                // A terminal ends its lines with `\r\n`, so a `\r` right before a newline ends
470                // the line rather than overwriting it. A second `\r` changes nothing, as on a
471                // screen: a program's own `\r\n` arrives as `\r\r\n` from a pseudo-terminal.
472                match byte {
473                    b'\r' => continue,
474                    b'\n' => {
475                        self.pending_return = false;
476                        emit(self.take());
477                        continue;
478                    }
479                    _ => {
480                        self.pending_return = false;
481                        self.overwrite(&mut overwritten);
482                    }
483                }
484            }
485            match byte {
486                b'\r' => self.pending_return = true,
487                b'\n' => emit(self.take()),
488                _ => {
489                    self.buffer.push(byte);
490                    if self.buffer.len() >= MAX_LINE {
491                        self.emit_piece(emit);
492                    }
493                }
494            }
495        }
496    }
497
498    /// Delivers the full buffer as a line of its own, keeping back the start of a character
499    /// that is not complete yet so no character is cut in two.
500    fn emit_piece(&mut self, emit: &mut impl FnMut(String)) {
501        // A character is at most four bytes, so only the last three can start one that is not
502        // complete yet. Anything else that is not UTF-8 is replaced as usual.
503        let len = self.buffer.len();
504        let mut cut = len;
505        for back in 1..=len.min(3) {
506            let byte = self.buffer[len - back];
507            if byte & 0b1100_0000 != 0b1000_0000 {
508                let width = match byte {
509                    0xc0..=0xdf => 2,
510                    0xe0..=0xef => 3,
511                    0xf0..=0xf7 => 4,
512                    _ => 1,
513                };
514                if width > back {
515                    cut = len - back;
516                }
517                break;
518            }
519        }
520        let rest = self.buffer.split_off(cut);
521        emit(self.take());
522        self.buffer = rest;
523    }
524
525    /// Drops the line a `\r` overwrites, or hands it to `overwritten` when there is one.
526    fn overwrite(&mut self, overwritten: &mut Option<&mut dyn FnMut(String)>) {
527        match overwritten {
528            Some(overwritten) if !self.buffer.is_empty() => overwritten(self.take()),
529            _ => self.buffer.clear(),
530        }
531    }
532
533    /// Delivers the last line when the stream ended without a newline.
534    pub(super) fn finish(&mut self, emit: &mut impl FnMut(String)) {
535        self.finish_keeping(emit, None);
536    }
537
538    /// Ends the stream like [`Lines::finish`]; a last line followed by a `\r` goes to
539    /// `overwritten` when it is given.
540    pub(super) fn finish_keeping(
541        &mut self,
542        emit: &mut impl FnMut(String),
543        mut overwritten: Option<&mut dyn FnMut(String)>,
544    ) {
545        if self.pending_return {
546            // The line was overwritten and nothing was written in its place.
547            self.overwrite(&mut overwritten);
548            self.pending_return = false;
549        }
550        if !self.buffer.is_empty() {
551            emit(self.take());
552        }
553    }
554
555    /// The line built so far, with anything that is not UTF-8 replaced rather than dropped.
556    fn take(&mut self) -> String {
557        let line = String::from_utf8_lossy(&self.buffer).into_owned();
558        self.buffer.clear();
559        line
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use std::sync::atomic::{AtomicUsize, Ordering};
566
567    use super::{Line, Lines, MAX_LINE, Process, ProcessOutcome};
568
569    /// Runs a shell command to its end and returns its lines and outcome.
570    fn shell(script: &str) -> (Vec<Line>, ProcessOutcome) {
571        run(Process::new("sh").args(["-c", script]))
572    }
573
574    /// Runs `process` to its end, never cancelling.
575    fn run(process: Process) -> (Vec<Line>, ProcessOutcome) {
576        let mut lines = Vec::new();
577        let outcome = process.run(&|| false, &mut |line| lines.push(line)).expect("the shell starts");
578        (lines, outcome)
579    }
580
581    #[test]
582    fn keeps_the_two_streams_apart_and_reports_the_exit_code() {
583        let (lines, outcome) = shell("echo bir; echo iki >&2; exit 3");
584        assert_eq!(lines.len(), 2, "{lines:?}");
585        assert!(lines.contains(&Line::Out("bir".to_owned())), "{lines:?}");
586        assert!(lines.contains(&Line::Err("iki".to_owned())), "{lines:?}");
587        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(3) });
588    }
589
590    #[test]
591    fn delivers_the_last_line_without_a_newline() {
592        let (lines, outcome) = shell("printf 'son satir'");
593        assert_eq!(lines, vec![Line::Out("son satir".to_owned())]);
594        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
595    }
596
597    #[test]
598    fn carriage_returns_collapse_into_one_line() {
599        let (lines, _) = shell(r"printf 'a\rbb\rccc\n'");
600        assert_eq!(lines, vec![Line::Out("ccc".to_owned())]);
601    }
602
603    #[test]
604    fn invalid_utf8_becomes_the_replacement_character() {
605        let (lines, _) = shell(r"printf 'a\377b\n'");
606        assert_eq!(lines, vec![Line::Out("a\u{fffd}b".to_owned())]);
607    }
608
609    #[test]
610    fn the_environment_is_inherited_and_one_variable_can_be_replaced() {
611        let (lines, _) = shell("echo ${PATH:+inherited}");
612        assert_eq!(lines, vec![Line::Out("inherited".to_owned())]);
613        let (lines, _) = run(Process::new("sh").args(["-c", "echo $LC_ALL"]).env("LC_ALL", "C"));
614        assert_eq!(lines, vec![Line::Out("C".to_owned())]);
615    }
616
617    #[test]
618    fn runs_in_the_directory_it_is_given() {
619        let (lines, _) = run(Process::new("sh").args(["-c", "pwd"]).dir("/"));
620        assert_eq!(lines, vec![Line::Out("/".to_owned())]);
621    }
622
623    #[test]
624    fn cancelling_kills_a_long_running_child() {
625        let seen = AtomicUsize::new(0);
626        let outcome = Process::new("sh")
627            .args(["-c", "while true; do echo tik; sleep 0.05; done"])
628            .run(&|| seen.load(Ordering::Relaxed) > 0, &mut |line| {
629                assert_eq!(line, Line::Out("tik".to_owned()));
630                seen.fetch_add(1, Ordering::Relaxed);
631            })
632            .expect("the shell starts");
633        assert_eq!(outcome, ProcessOutcome::Cancelled);
634        assert!(seen.load(Ordering::Relaxed) > 0);
635    }
636
637    #[test]
638    fn a_missing_program_is_an_error_and_not_a_panic() {
639        let error = Process::new("quvyta-no-such-program")
640            .run(&|| false, &mut |_| unreachable!("a missing program writes nothing"))
641            .expect_err("a missing program cannot run");
642        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
643    }
644
645    #[cfg(unix)]
646    #[test]
647    fn on_a_pseudo_terminal_the_child_sees_a_terminal_of_the_size_we_gave() {
648        // `stty` reads its standard input, which is the application's own; reading the size the
649        // child was given means asking about the stream it writes to.
650        let (lines, outcome) = run(Process::new("sh").args(["-c", "test -t 1 && stty size <&1"]).pty(100, 24));
651        assert_eq!(lines, vec![Line::Out("24 100".to_owned())]);
652        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
653    }
654
655    #[cfg(unix)]
656    #[test]
657    fn on_a_pseudo_terminal_both_streams_arrive_as_output() {
658        let (lines, outcome) = run(Process::new("sh").args(["-c", "echo bir; echo iki >&2"]).pty(80, 24));
659        assert_eq!(lines, vec![Line::Out("bir".to_owned()), Line::Out("iki".to_owned())]);
660        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
661    }
662
663    /// The process group, session and controlling terminal in a line of `/proc/<pid>/stat`.
664    #[cfg(target_os = "linux")]
665    fn stat_ids(stat: &str) -> [String; 3] {
666        // The command name may hold spaces; after its closing parenthesis come state, parent,
667        // group, session and terminal.
668        let fields: Vec<&str> = stat[stat.rfind(')').expect("name") + 2..].split(' ').collect();
669        [fields[2], fields[3], fields[4]].map(str::to_owned)
670    }
671
672    #[cfg(target_os = "linux")]
673    #[test]
674    fn without_stdin_the_child_reads_an_empty_stream() {
675        let script = r#"readlink /proc/$$/fd/0; read answer; echo "read $?""#;
676        for process in [Process::new("sh").args(["-c", script]), Process::new("sh").args(["-c", script]).pty(80, 24)] {
677            let (lines, outcome) = run(process.no_stdin());
678            assert_eq!(lines, vec![Line::Out("/dev/null".to_owned()), Line::Out("read 1".to_owned())]);
679            assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
680        }
681    }
682
683    #[cfg(target_os = "linux")]
684    #[test]
685    fn only_a_child_without_stdin_gets_a_group_of_its_own_and_it_keeps_the_session() {
686        let script = "cat /proc/$$/stat";
687        let ours = stat_ids(&std::fs::read_to_string("/proc/self/stat").expect("stat"));
688        let ids = |process: Process| {
689            let (lines, _) = run(process);
690            let [Line::Out(stat)] = &lines[..] else { panic!("one line: {lines:?}") };
691            stat_ids(stat)
692        };
693        let shared = ids(Process::new("sh").args(["-c", script]));
694        assert_eq!(shared, ours, "a child reading the terminal stays in the application's group");
695        for process in [Process::new("sh").args(["-c", script]), Process::new("sh").args(["-c", script]).pty(80, 24)] {
696            let [group, session, terminal] = ids(process.no_stdin());
697            assert_ne!(group, ours[0], "a group of its own");
698            // `sudo` keeps its ticket per controlling terminal and session, so both must stay.
699            assert_eq!(session, ours[1], "the application's session");
700            assert_eq!(terminal, ours[2], "the application's controlling terminal");
701        }
702    }
703
704    /// Whether `pid` has ended: gone, or ended and waiting for its parent to collect it.
705    #[cfg(target_os = "linux")]
706    fn ended(pid: &str) -> bool {
707        std::fs::read_to_string(format!("/proc/{pid}/stat"))
708            .map_or(true, |stat| stat[stat.rfind(')').expect("name") + 2..].starts_with('Z'))
709    }
710
711    #[cfg(target_os = "linux")]
712    #[test]
713    fn cancelling_a_child_without_stdin_ends_the_programs_it_started() {
714        for pty in [false, true] {
715            let seen = std::cell::RefCell::new(Vec::new());
716            let process = Process::new("sh").args(["-c", "sleep 60 & echo $!; sleep 60 & echo $!; wait"]).no_stdin();
717            let process = if pty { process.pty(80, 24) } else { process };
718            let outcome = process
719                .run(&|| seen.borrow().len() == 2, &mut |line| match line {
720                    Line::Out(pid) => seen.borrow_mut().push(pid),
721                    Line::Err(text) => panic!("nothing on standard error: {text}"),
722                })
723                .expect("the shell starts");
724            assert_eq!(outcome, ProcessOutcome::Cancelled);
725            let pids = seen.into_inner();
726            let started = std::time::Instant::now();
727            while !pids.iter().all(|pid| ended(pid)) {
728                assert!(started.elapsed() < std::time::Duration::from_secs(20), "still running: {pids:?} (pty {pty})");
729                std::thread::sleep(std::time::Duration::from_millis(20));
730            }
731        }
732    }
733
734    #[cfg(target_os = "linux")]
735    #[test]
736    fn cancelling_a_child_that_shares_stdin_ends_only_the_child() {
737        // Documented: without `no_stdin` the child stays in the application's group, and its
738        // own children outlive it. They are ended here by hand so the test leaves nothing behind.
739        let seen = std::cell::RefCell::new(Vec::new());
740        let outcome = Process::new("sh")
741            .args(["-c", "sleep 60 & echo $!; wait"])
742            .run(&|| seen.borrow().len() == 1, &mut |line| {
743                if let Line::Out(pid) = line {
744                    seen.borrow_mut().push(pid);
745                }
746            })
747            .expect("the shell starts");
748        assert_eq!(outcome, ProcessOutcome::Cancelled);
749        let pid = seen.into_inner().remove(0);
750        std::thread::sleep(std::time::Duration::from_millis(200));
751        let survived = !ended(&pid);
752        let raw: i32 = pid.parse().expect("a process id");
753        if let Some(pid) = rustix::process::Pid::from_raw(raw) {
754            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
755        }
756        assert!(survived, "the grandchild outlives a cancel of the child");
757    }
758
759    #[test]
760    fn a_line_ended_twice_by_a_return_is_kept() {
761        // A program that ends its own lines with `\r\n` writes `\r\r\n` on a pseudo-terminal,
762        // which turns every `\n` into `\r\n`. The line is on the screen, so it is not lost.
763        let mut lines = Lines::default();
764        let mut seen = Vec::new();
765        lines.feed(b"hazir\r\r\nbitti\r\r\r\n", &mut |line| seen.push(line));
766        assert_eq!(seen, vec!["hazir".to_owned(), "bitti".to_owned()]);
767    }
768
769    #[cfg(unix)]
770    #[test]
771    fn a_pseudo_terminal_line_ended_by_the_program_itself_arrives_whole() {
772        let (lines, _) = run(Process::new("sh").args(["-c", r"printf 'bir\r\niki\r\n'"]).pty(80, 24));
773        assert_eq!(lines, vec![Line::Out("bir".to_owned()), Line::Out("iki".to_owned())]);
774    }
775
776    #[test]
777    fn a_line_without_an_end_is_delivered_in_pieces_of_bounded_size() {
778        // A program that never writes a newline must not make the reader hold all of it.
779        let (lines, _) = shell("head -c 300000 /dev/zero | tr '\\0' a");
780        let total: usize = lines
781            .iter()
782            .map(|line| match line {
783                Line::Out(text) => {
784                    assert!(text.len() <= MAX_LINE, "a piece of {} bytes", text.len());
785                    assert!(text.bytes().all(|byte| byte == b'a'));
786                    text.len()
787                }
788                Line::Err(text) => panic!("nothing was written to standard error: {text}"),
789            })
790            .sum();
791        assert_eq!(total, 300_000, "nothing is lost between the pieces");
792    }
793
794    #[test]
795    fn a_long_line_is_never_cut_inside_a_character() {
796        let mut lines = Lines::default();
797        let mut seen = Vec::new();
798        // One byte of padding, so the two-byte `ç` straddles every piece boundary.
799        let mut text = vec![b'a'];
800        for _ in 0..MAX_LINE {
801            text.extend_from_slice("ç".as_bytes());
802        }
803        lines.feed(&text, &mut |line| seen.push(line));
804        lines.finish(&mut |line| seen.push(line));
805        assert!(seen.len() > 1, "the line was split");
806        assert!(seen.iter().all(|line| !line.contains('\u{fffd}')), "no character was cut in two");
807        assert_eq!(seen.concat().as_bytes(), text.as_slice());
808    }
809
810    #[test]
811    fn a_child_that_closes_its_output_can_still_be_cancelled() {
812        // Its streams end at once, but it keeps running; cancelling must still stop it.
813        let started = std::time::Instant::now();
814        let outcome = Process::new("sh")
815            .args(["-c", "exec >&- 2>&-; sleep 20"])
816            .run(&|| started.elapsed() > std::time::Duration::from_millis(200), &mut |_| {})
817            .expect("the shell starts");
818        assert_eq!(outcome, ProcessOutcome::Cancelled);
819        assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
820    }
821
822    #[test]
823    fn a_flood_of_output_waits_for_the_reader_instead_of_piling_up() {
824        let dir = std::env::temp_dir().join(format!("quvyta-process-flood-{}", std::process::id()));
825        let _ = std::fs::remove_dir_all(&dir);
826        std::fs::create_dir_all(&dir).expect("test directory");
827        let marker = dir.join("done");
828        let script = format!("yes | head -n 200000; touch '{}'", marker.display());
829        let mut first = true;
830        let mut finished_while_the_reader_slept = false;
831        let mut count = 0_usize;
832        let outcome = Process::new("sh")
833            .args(["-c", &script])
834            .run(&|| false, &mut |_| {
835                count += 1;
836                if first {
837                    first = false;
838                    // Far longer than writing 200000 short lines takes when nothing holds it back.
839                    std::thread::sleep(std::time::Duration::from_millis(700));
840                    finished_while_the_reader_slept = marker.exists();
841                }
842            })
843            .expect("the shell starts");
844        assert_eq!(outcome, ProcessOutcome::Finished { code: Some(0) });
845        assert_eq!(count, 200_000);
846        assert!(!finished_while_the_reader_slept, "the child wrote everything into memory while nobody read");
847        std::fs::remove_dir_all(&dir).expect("clean");
848    }
849
850    #[cfg(target_os = "linux")]
851    #[test]
852    fn the_child_on_a_pseudo_terminal_holds_it_only_on_its_own_streams() {
853        // Any other descriptor of the terminal would outlive the streams in the child and in the
854        // programs it starts, and keep the reader waiting after they are closed.
855        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"#;
856        let (lines, _) = run(Process::new("sh").args(["-c", script]).pty(80, 24));
857        assert_eq!(lines, vec![Line::Out("2".to_owned())], "standard output and standard error, nothing else");
858    }
859
860    #[test]
861    fn a_line_split_across_reads_stays_one_line() {
862        let mut lines = Lines::default();
863        let mut seen = Vec::new();
864        let mut emit = |line: String| seen.push(line);
865        lines.feed(b"ilk par", &mut emit);
866        lines.feed(b"\xc3", &mut emit);
867        lines.feed(b"\xa7a\r\nson", &mut emit);
868        lines.finish(&mut emit);
869        assert_eq!(seen, vec!["ilk parça".to_owned(), "son".to_owned()]);
870    }
871
872    /// Feeds `bytes` in one go and ends the stream, returning the lines and the overwritten
873    /// frames.
874    fn split_keeping_frames(bytes: &[u8]) -> (Vec<String>, Vec<String>) {
875        let mut lines = Lines::default();
876        let (mut seen, mut frames) = (Vec::new(), Vec::new());
877        lines.feed_keeping(bytes, &mut |line| seen.push(line), Some(&mut |frame| frames.push(frame)));
878        lines.finish_keeping(&mut |line| seen.push(line), Some(&mut |frame| frames.push(frame)));
879        (seen, frames)
880    }
881
882    #[test]
883    fn frames_overwritten_by_a_return_are_kept_only_when_asked_for() {
884        let (seen, frames) = split_keeping_frames(b"bir\riki\ruc\rbitti\r\n");
885        assert_eq!(seen, vec!["bitti".to_owned()]);
886        assert_eq!(frames, vec!["bir".to_owned(), "iki".to_owned(), "uc".to_owned()]);
887        let mut lines = Lines::default();
888        let mut seen = Vec::new();
889        lines.feed(b"bir\riki\ruc\rbitti\r\n", &mut |line| seen.push(line));
890        lines.finish(&mut |line| seen.push(line));
891        assert_eq!(seen, vec!["bitti".to_owned()]);
892    }
893
894    #[test]
895    fn a_line_ended_by_returns_and_a_newline_is_no_frame() {
896        let (seen, frames) = split_keeping_frames(b"hazir\r\r\nbitti\r\n\rbos\r\r\r\n");
897        assert_eq!(seen, vec!["hazir".to_owned(), "bitti".to_owned(), "bos".to_owned()]);
898        assert!(frames.is_empty(), "{frames:?}");
899    }
900
901    #[test]
902    fn a_stream_ending_in_a_return_delivers_its_last_frame() {
903        let (seen, frames) = split_keeping_frames(b"once\r10%\r20%\r");
904        assert!(seen.is_empty(), "{seen:?}");
905        assert_eq!(frames, vec!["once".to_owned(), "10%".to_owned(), "20%".to_owned()]);
906        // A return split from what follows it by a read still waits for that byte.
907        let mut lines = Lines::default();
908        let (mut seen, mut frames) = (Vec::new(), Vec::new());
909        lines.feed_keeping(b"30%\r", &mut |line| seen.push(line), Some(&mut |frame| frames.push(frame)));
910        assert!(frames.is_empty(), "a return before a newline is not yet known to overwrite");
911        lines.feed_keeping(b"\n", &mut |line| seen.push(line), Some(&mut |frame| frames.push(frame)));
912        assert_eq!((seen, frames), (vec!["30%".to_owned()], Vec::new()));
913    }
914
915    #[test]
916    fn frames_keep_their_colour_and_erase_codes() {
917        let (seen, frames) = split_keeping_frames(b"\x1b[1mFetch\x1b[0m 1\r\x1b[K\x1b[92mDone\x1b[0m\r\n");
918        assert_eq!(frames, vec!["\x1b[1mFetch\x1b[0m 1".to_owned()]);
919        assert_eq!(seen, vec!["\x1b[K\x1b[92mDone\x1b[0m".to_owned()]);
920    }
921
922    #[test]
923    fn every_frame_of_a_recorded_cargo_install_is_kept() {
924        let recorded = include_bytes!("../../tests/fixtures/cargo-install-pty.txt");
925        let (seen, frames) = split_keeping_frames(recorded);
926        assert_eq!(frames.len(), 159, "every overwritten frame");
927        assert_eq!(seen.len(), 76, "the lines themselves are unchanged");
928        let building: Vec<&String> = frames.iter().filter(|frame| frame.contains("Building")).collect();
929        assert_eq!(building.len(), 51);
930        assert!(building[0].contains("] 0/46: anstyle"), "{:?}", building[0]);
931        assert!(building.iter().any(|frame| frame.contains("] 45/46: hexyl")), "{building:?}");
932        // Without asking, the same bytes give the same lines and no frame reaches anyone.
933        let mut lines = Lines::default();
934        let mut plain = Vec::new();
935        lines.feed(recorded, &mut |line| plain.push(line));
936        lines.finish(&mut |line| plain.push(line));
937        assert_eq!(plain, seen);
938    }
939
940    /// Runs `process` to its end asking for overwritten frames, returning the lines and frames in
941    /// the order they arrived.
942    fn run_keeping_frames(process: Process) -> Vec<(bool, Line)> {
943        let seen = std::cell::RefCell::new(Vec::new());
944        process
945            .run_with_overwritten(&|| false, &mut |line| seen.borrow_mut().push((false, line)), &mut |frame| {
946                seen.borrow_mut().push((true, frame));
947            })
948            .expect("the shell starts");
949        seen.into_inner()
950    }
951
952    #[test]
953    fn overwritten_frames_arrive_through_a_pipe_in_order_and_tagged_by_stream() {
954        let seen = run_keeping_frames(Process::new("sh").args(["-c", r"printf 'a\rb\rc\n'; printf '1%%\r2%%\r' >&2"]));
955        let out: Vec<_> = seen.iter().filter(|(_, line)| matches!(line, Line::Out(_))).cloned().collect();
956        let err: Vec<_> = seen.iter().filter(|(_, line)| matches!(line, Line::Err(_))).cloned().collect();
957        assert_eq!(
958            out,
959            vec![
960                (true, Line::Out("a".to_owned())),
961                (true, Line::Out("b".to_owned())),
962                (false, Line::Out("c".to_owned()))
963            ]
964        );
965        assert_eq!(err, vec![(true, Line::Err("1%".to_owned())), (true, Line::Err("2%".to_owned()))]);
966    }
967
968    #[cfg(unix)]
969    #[test]
970    fn overwritten_frames_arrive_from_a_pseudo_terminal() {
971        let seen = run_keeping_frames(Process::new("sh").args(["-c", r"printf 'a\rb\rc\nd\r\n'"]).pty(80, 24));
972        assert_eq!(
973            seen,
974            vec![
975                (true, Line::Out("a".to_owned())),
976                (true, Line::Out("b".to_owned())),
977                (false, Line::Out("c".to_owned())),
978                (false, Line::Out("d".to_owned())),
979            ]
980        );
981    }
982}