1use 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
22const POLL: Duration = Duration::from_millis(10);
24
25pub(super) const CHUNK: usize = 4096;
27
28const MAX_LINE: usize = 64 * 1024;
31
32const QUEUE: usize = 1024;
36
37#[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#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Line {
64 Out(String),
66 Err(String),
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum ProcessOutcome {
73 Finished {
75 code: Option<i32>,
77 },
78 Cancelled,
80}
81
82impl Process {
83 #[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 #[must_use]
91 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
92 self.args.push(arg.into());
93 self
94 }
95
96 #[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 #[must_use]
105 pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
106 self.dir = Some(dir.into());
107 self
108 }
109
110 #[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 #[must_use]
127 pub fn pty(mut self, cols: u16, rows: u16) -> Self {
128 self.pty = Some((cols, rows));
129 self
130 }
131
132 #[must_use]
144 pub fn no_stdin(mut self) -> Self {
145 self.no_stdin = true;
146 self
147 }
148
149 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 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 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 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 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 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
291enum Sent {
294 Line(Line),
295 Overwritten(Line),
296}
297
298fn kill(child: &mut Child, group: bool) {
301 #[cfg(unix)]
302 if group {
303 let leader = rustix::process::Pid::from_child(child);
304 let _ = rustix::process::kill_process_group(leader, rustix::process::Signal::KILL);
307 }
308 #[cfg(not(unix))]
309 let _ = group;
310 let _ = child.kill();
314 let _ = child.wait();
315}
316
317fn 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#[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 #[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 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 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#[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
403fn 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
418fn 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 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 Err(_) => break,
439 }
440 }
441 lines.finish_keeping(&mut on_line, frames.then_some(&mut on_frame));
442}
443
444#[derive(Debug, Default)]
447pub(super) struct Lines {
448 buffer: Vec<u8>,
449 pending_return: bool,
451}
452
453impl Lines {
454 pub(super) fn feed(&mut self, bytes: &[u8], emit: &mut impl FnMut(String)) {
456 self.feed_keeping(bytes, emit, None);
457 }
458
459 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 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 fn emit_piece(&mut self, emit: &mut impl FnMut(String)) {
501 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 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 pub(super) fn finish(&mut self, emit: &mut impl FnMut(String)) {
535 self.finish_keeping(emit, None);
536 }
537
538 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 self.overwrite(&mut overwritten);
548 self.pending_return = false;
549 }
550 if !self.buffer.is_empty() {
551 emit(self.take());
552 }
553 }
554
555 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 fn shell(script: &str) -> (Vec<Line>, ProcessOutcome) {
571 run(Process::new("sh").args(["-c", script]))
572 }
573
574 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 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 #[cfg(target_os = "linux")]
665 fn stat_ids(stat: &str) -> [String; 3] {
666 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 assert_eq!(session, ours[1], "the application's session");
700 assert_eq!(terminal, ours[2], "the application's controlling terminal");
701 }
702 }
703
704 #[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 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 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 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 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 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 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 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 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 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 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 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}