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();
311 let _ = child.wait();
312}
313
314fn 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#[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 #[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 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 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#[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
400fn 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
415fn 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 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 Err(_) => break,
436 }
437 }
438 lines.finish_keeping(&mut on_line, frames.then_some(&mut on_frame));
439}
440
441#[derive(Debug, Default)]
444pub(super) struct Lines {
445 buffer: Vec<u8>,
446 pending_return: bool,
448}
449
450impl Lines {
451 pub(super) fn feed(&mut self, bytes: &[u8], emit: &mut impl FnMut(String)) {
453 self.feed_keeping(bytes, emit, None);
454 }
455
456 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 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 fn emit_piece(&mut self, emit: &mut impl FnMut(String)) {
498 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 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 pub(super) fn finish(&mut self, emit: &mut impl FnMut(String)) {
532 self.finish_keeping(emit, None);
533 }
534
535 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 self.overwrite(&mut overwritten);
545 self.pending_return = false;
546 }
547 if !self.buffer.is_empty() {
548 emit(self.take());
549 }
550 }
551
552 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 fn shell(script: &str) -> (Vec<Line>, ProcessOutcome) {
568 run(Process::new("sh").args(["-c", script]))
569 }
570
571 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 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 #[cfg(target_os = "linux")]
662 fn stat_ids(stat: &str) -> [String; 3] {
663 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 assert_eq!(session, ours[1], "the application's session");
697 assert_eq!(terminal, ours[2], "the application's controlling terminal");
698 }
699 }
700
701 #[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 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 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 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 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 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 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 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 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 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 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 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}