1use std::collections::HashMap;
16use std::io::{Read, Write};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU64, Ordering};
20use std::time::{Duration, Instant};
21
22use alacritty_terminal::event::{Event, EventListener, OnResize, WindowSize};
23use alacritty_terminal::grid::Dimensions;
24use alacritty_terminal::sync::FairMutex;
25use alacritty_terminal::term::{Config, Term};
26use alacritty_terminal::tty::{self, ChildEvent, EventedPty, EventedReadWrite, Options, Shell};
27use alacritty_terminal::vte::ansi::Processor;
28use parking_lot::Mutex;
29use rustix::event::{PollFd, PollFlags, Timespec, poll};
30use rustix::process::{Pid, Signal, kill_process_group};
31use tokio::sync::{broadcast, watch};
32
33use crate::osc::Osc133;
34use crate::parser;
35use crate::screen::{self, ReadResult, Screen};
36use crate::shell;
37use crate::state::{self, ClassifyInput, Exit, State};
38
39const QUIET: Duration = Duration::from_millis(150);
42
43const OUTPUT_BACKLOG: usize = 256;
45
46const WRITE_RETRIES: u32 = 100;
48
49pub const MIN_COLS: u16 = 2;
52pub const MAX_COLS: u16 = 500;
53pub const MIN_ROWS: u16 = 2;
54pub const MAX_ROWS: u16 = 300;
55
56#[derive(Clone, Debug)]
58pub struct TerminalConfig {
59 pub shell: String,
61 pub cwd: PathBuf,
62 pub env: HashMap<String, String>,
64 pub cols: u16,
65 pub rows: u16,
66 pub scrollback_lines: usize,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct OutputChunk {
72 pub seq: u32,
73 pub bytes: Vec<u8>,
74}
75
76#[derive(Debug, thiserror::Error)]
78pub enum Error {
79 #[error("the terminal's PTY could not be opened: {0}")]
80 Spawn(String),
81 #[error("the terminal is closed")]
82 Closed,
83 #[error("the terminal's input could not be written: {0}")]
84 Write(String),
85 #[error(
86 "{cols}x{rows} is outside the terminal's bounds ({MIN_COLS}..={MAX_COLS} columns, \
87 {MIN_ROWS}..={MAX_ROWS} rows)"
88 )]
89 Resize { cols: u16, rows: u16 },
90}
91
92pub struct Terminal {
94 inner: Arc<Inner>,
95 thread: Mutex<Option<std::thread::JoinHandle<()>>>,
96}
97
98impl Terminal {
99 pub fn spawn(config: &TerminalConfig) -> Result<Self, Error> {
101 let cwd = config.cwd.canonicalize().map_err(|error| {
105 Error::Spawn(format!(
106 "the terminal's cwd {} cannot be used: {error}",
107 config.cwd.display()
108 ))
109 })?;
110 if !cwd.is_dir() {
111 return Err(Error::Spawn(format!(
112 "the terminal's cwd {} is not a directory",
113 cwd.display()
114 )));
115 }
116 let plan = shell::plan(&config.shell, &config.env);
117 let options = Options {
118 shell: Some(Shell::new(plan.program.clone(), plan.args.clone())),
119 working_directory: Some(cwd.clone()),
120 drain_on_exit: true,
121 env: plan.env.clone(),
122 };
123 let window = WindowSize {
124 num_lines: config.rows,
125 num_cols: config.cols,
126 cell_width: 0,
127 cell_height: 0,
128 };
129 let pty = tty::new(&options, window, 0).map_err(|error| Error::Spawn(error.to_string()))?;
130 let writer = pty
131 .file()
132 .try_clone()
133 .map_err(|error| Error::Spawn(format!("the master fd could not be cloned: {error}")))?;
134 let probe = pty
135 .file()
136 .try_clone()
137 .map_err(|error| Error::Spawn(format!("the master fd could not be cloned: {error}")))?;
138 let child_pid = i32::try_from(pty.child().id()).unwrap_or(0);
139
140 let events = Arc::new(Mutex::new(Vec::new()));
141 let listener = Listener { events: Arc::clone(&events) };
142 let term = Term::new(
143 Config { scrolling_history: config.scrollback_lines, ..Default::default() },
144 &Size { cols: usize::from(config.cols), rows: usize::from(config.rows) },
145 listener,
146 );
147 let initial_lines = u64::try_from(term.grid().total_lines()).unwrap_or(0);
148 let (output, _) = broadcast::channel(OUTPUT_BACKLOG);
149 let (exit_tx, exit_rx) = watch::channel(false);
150 let (state_tx, state_rx) = watch::channel(State {
151 running: false,
152 input_expected: None,
153 exit: None,
154 source: state::Source::Heuristic,
155 });
156 let (resize_tx, resize_rx) = std::sync::mpsc::channel();
157 let inner = Arc::new(Inner {
158 writer: Mutex::new(writer),
159 probe: Mutex::new(probe),
160 term: FairMutex::new(term),
161 events,
162 osc: Mutex::new(Osc133::new()),
163 output,
164 state_tx,
165 state_rx,
166 exit_tx,
167 exit_rx,
168 seq: AtomicU32::new(1),
172 lines: AtomicU64::new(initial_lines),
173 history: config.scrollback_lines,
174 child_pid,
175 closed: AtomicBool::new(false),
176 alive: AtomicBool::new(true),
177 cols: AtomicU16::new(config.cols),
178 rows: AtomicU16::new(config.rows),
179 shell: config.shell.clone(),
180 cwd,
181 shim: plan.shim.clone(),
182 last_output: Mutex::new(Instant::now()),
183 shell_exit: Mutex::new(None),
184 title: Mutex::new(String::new()),
185 resize_tx,
186 });
187 tracing::debug!(
188 event_name = "terminal_spawned",
189 shell = %config.shell,
190 cwd = %config.cwd.display(),
191 cols = config.cols,
192 rows = config.rows,
193 pid = child_pid,
194 "a terminal was spawned"
195 );
196 let thread = {
197 let inner = Arc::clone(&inner);
198 std::thread::Builder::new()
199 .name("forge-terminal".to_owned())
200 .spawn(move || read_loop(&inner, pty, &resize_rx))
201 .map_err(|error| {
202 Error::Spawn(format!("the read thread could not start: {error}"))
203 })?
204 };
205 Ok(Self { inner, thread: Mutex::new(Some(thread)) })
206 }
207
208 pub fn write_text(&self, text: &str) -> Result<(), Error> {
210 self.inner.write(text.as_bytes())
211 }
212
213 pub fn write_bytes(&self, bytes: &[u8]) -> Result<(), Error> {
216 self.inner.write(bytes)
217 }
218
219 pub fn write_keys(&self, keys: &[crate::keys::Key]) -> Result<(), Error> {
221 for key in keys {
222 self.inner.write(key.bytes())?;
223 }
224 Ok(())
225 }
226
227 pub fn resize(&self, cols: u16, rows: u16) -> Result<(), Error> {
229 if self.inner.closed.load(Ordering::Relaxed) {
230 return Err(Error::Closed);
231 }
232 if !(MIN_COLS..=MAX_COLS).contains(&cols) || !(MIN_ROWS..=MAX_ROWS).contains(&rows) {
233 return Err(Error::Resize { cols, rows });
234 }
235 {
236 let mut term = self.inner.term.lock();
240 let mut lines = self.inner.lines.load(Ordering::Relaxed);
241 screen::resize(&mut term, usize::from(cols), usize::from(rows), &mut lines);
242 self.inner.lines.store(lines, Ordering::Relaxed);
243 }
244 self.inner.cols.store(cols, Ordering::Relaxed);
245 self.inner.rows.store(rows, Ordering::Relaxed);
246 let _ = self.inner.resize_tx.send((cols, rows));
247 Ok(())
248 }
249
250 pub fn close(&self) {
252 self.inner.close();
253 }
254
255 pub fn screen(&self) -> Screen {
257 let term = self.inner.term.lock();
258 screen::extract(&term)
259 }
260
261 pub fn read(&self, since: Option<u64>, max_lines: usize, max_bytes: usize) -> ReadResult {
263 let term = self.inner.term.lock();
264 let appended = self.inner.lines.load(Ordering::Relaxed);
265 screen::read(&term, appended, since, max_lines, max_bytes)
266 }
267
268 pub fn repaint_and_seq(&self) -> (Vec<u8>, u32) {
273 let term = self.inner.term.lock();
274 (crate::repaint::repaint(&term), self.inner.seq.load(Ordering::Relaxed))
275 }
276
277 pub fn state(&self) -> State {
279 self.inner.state()
280 }
281
282 pub fn subscribe(&self) -> broadcast::Receiver<OutputChunk> {
284 self.inner.output.subscribe()
285 }
286
287 pub fn state_stream(&self) -> watch::Receiver<State> {
289 self.inner.state_rx.clone()
290 }
291
292 pub fn exit_stream(&self) -> watch::Receiver<bool> {
294 self.inner.exit_rx.clone()
295 }
296
297 pub fn is_alive(&self) -> bool {
299 self.inner.alive.load(Ordering::Relaxed)
300 }
301
302 pub fn shell_exit(&self) -> Option<Exit> {
304 *self.inner.shell_exit.lock()
305 }
306
307 pub fn cols(&self) -> u16 {
308 self.inner.cols.load(Ordering::Relaxed)
309 }
310
311 pub fn rows(&self) -> u16 {
312 self.inner.rows.load(Ordering::Relaxed)
313 }
314
315 pub fn shell(&self) -> &str {
316 &self.inner.shell
317 }
318
319 pub fn cwd(&self) -> &Path {
320 &self.inner.cwd
321 }
322
323 pub fn title(&self) -> String {
325 self.inner.title.lock().clone()
326 }
327}
328
329impl Drop for Terminal {
330 fn drop(&mut self) {
331 self.inner.close();
332 if let Some(thread) = self.thread.lock().take() {
333 let _ = thread.join();
334 }
335 if let Some(shim) = &self.inner.shim {
336 let _ = std::fs::remove_dir_all(shim);
337 }
338 }
339}
340
341#[derive(Clone)]
343struct Listener {
344 events: Arc<Mutex<Vec<Event>>>,
345}
346
347impl EventListener for Listener {
348 fn send_event(&self, event: Event) {
349 self.events.lock().push(event);
350 }
351}
352
353struct Size {
354 cols: usize,
355 rows: usize,
356}
357
358impl Dimensions for Size {
359 fn total_lines(&self) -> usize {
360 self.rows
361 }
362 fn screen_lines(&self) -> usize {
363 self.rows
364 }
365 fn columns(&self) -> usize {
366 self.cols
367 }
368}
369
370struct Inner {
371 writer: Mutex<std::fs::File>,
372 probe: Mutex<std::fs::File>,
373 term: FairMutex<Term<Listener>>,
374 events: Arc<Mutex<Vec<Event>>>,
375 osc: Mutex<Osc133>,
376 output: broadcast::Sender<OutputChunk>,
377 state_tx: watch::Sender<State>,
378 state_rx: watch::Receiver<State>,
379 exit_tx: watch::Sender<bool>,
380 exit_rx: watch::Receiver<bool>,
381 seq: AtomicU32,
382 lines: AtomicU64,
386 history: usize,
387 child_pid: i32,
388 closed: AtomicBool,
389 alive: AtomicBool,
390 cols: AtomicU16,
391 rows: AtomicU16,
392 shell: String,
393 cwd: PathBuf,
394 shim: Option<PathBuf>,
395 last_output: Mutex<Instant>,
396 shell_exit: Mutex<Option<Exit>>,
397 title: Mutex<String>,
398 resize_tx: std::sync::mpsc::Sender<(u16, u16)>,
399}
400
401impl Inner {
402 fn write(&self, bytes: &[u8]) -> Result<(), Error> {
403 if self.closed.load(Ordering::Relaxed) {
404 return Err(Error::Closed);
405 }
406 let mut writer = self.writer.lock();
407 let mut sent = 0usize;
410 for _ in 0..WRITE_RETRIES {
411 match writer.write(&bytes[sent..]) {
412 Ok(written) => {
413 sent += written;
414 if sent == bytes.len() {
415 *self.last_output.lock() = Instant::now();
416 return Ok(());
417 }
418 }
419 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
420 Err(error) => return Err(Error::Write(error.to_string())),
421 }
422 std::thread::sleep(Duration::from_millis(1));
423 }
424 Err(Error::Write("the PTY did not accept the input".to_owned()))
425 }
426
427 fn echo(&self) -> bool {
428 let probe = self.probe.lock();
429 rustix::termios::tcgetattr(&*probe)
430 .map_or(true, |termios| termios.local_modes.contains(rustix::termios::LocalModes::ECHO))
431 }
432
433 fn close(&self) {
434 if self.closed.swap(true, Ordering::SeqCst) {
435 return;
436 }
437 if let Some(pid) = Pid::from_raw(self.child_pid) {
438 let _ = kill_process_group(pid, Signal::KILL);
439 }
440 }
441
442 fn publish_state(&self) {
443 let state = self.state();
444 let _ = self.state_tx.send_if_modified(|current| {
447 if *current == state {
448 return false;
449 }
450 *current = state;
451 true
452 });
453 }
454
455 fn state(&self) -> State {
459 if !self.alive.load(Ordering::Relaxed) {
460 let exit =
461 self.shell_exit.lock().map(|exit| Exit { code: exit.code, signal: exit.signal });
462 return State {
463 running: false,
464 input_expected: None,
465 exit,
466 source: state::Source::Exact,
467 };
468 }
469 let marks = self.osc.lock().marks();
470 let echo = self.echo();
471 let quiet = self.last_output.lock().elapsed() > QUIET;
472 let last_line = {
473 let term = self.term.lock();
474 last_nonempty_line(&term)
475 };
476 state::classify(ClassifyInput { marks, echo, quiet, last_line: &last_line })
477 }
478}
479
480fn read_loop(
482 inner: &Arc<Inner>,
483 mut pty: tty::Pty,
484 resize_rx: &std::sync::mpsc::Receiver<(u16, u16)>,
485) {
486 let mut parser: Processor = Processor::new();
487 let mut buf = vec![0u8; 65536];
488 let timeout = Timespec { tv_sec: 0, tv_nsec: 100_000_000 };
489 loop {
490 if inner.closed.load(Ordering::Relaxed) {
491 break;
492 }
493 while let Ok((cols, rows)) = resize_rx.try_recv() {
494 let window =
495 WindowSize { num_lines: rows, num_cols: cols, cell_width: 0, cell_height: 0 };
496 pty.on_resize(window);
497 }
498
499 let mut fds = [PollFd::new(pty.file(), PollFlags::IN)];
500 let mut ended = false;
501 match poll(&mut fds, Some(&timeout)) {
502 Ok(0) => {}
503 Ok(_) => {
504 let flags = fds[0].revents();
505 if flags.contains(PollFlags::IN) {
506 read_ready(inner, &mut pty, &mut parser, &mut buf);
507 }
508 ended = poll_ended(flags);
509 }
510 Err(rustix::io::Errno::INTR) => continue,
511 Err(error) => {
512 tracing::debug!(
513 event_name = "terminal_poll_failed",
514 pid = inner.child_pid,
515 %error,
516 "the terminal's poll failed; the terminal ends"
517 );
518 break;
519 }
520 }
521
522 if let Some(ChildEvent::Exited(status)) = pty.next_child_event() {
525 let exit =
526 status.map(|status| Exit { code: status.code(), signal: exit_signal(status) });
527 *inner.shell_exit.lock() = exit;
528 tracing::debug!(
529 event_name = "terminal_exited",
530 pid = inner.child_pid,
531 exit = ?exit,
532 "a terminal's shell exited"
533 );
534 break;
535 }
536 if ended {
537 inner.close();
541 let deadline = Instant::now() + Duration::from_millis(200);
546 loop {
547 if let Some(ChildEvent::Exited(status)) = pty.next_child_event() {
548 let exit = status
549 .map(|status| Exit { code: status.code(), signal: exit_signal(status) });
550 *inner.shell_exit.lock() = exit;
551 tracing::debug!(
552 event_name = "terminal_exited",
553 pid = inner.child_pid,
554 exit = ?exit,
555 "a terminal's shell exited"
556 );
557 break;
558 }
559 if Instant::now() >= deadline {
560 break;
561 }
562 std::thread::sleep(Duration::from_millis(10));
563 }
564 break;
565 }
566 }
567 inner.alive.store(false, Ordering::Relaxed);
568 inner.publish_state();
569 let _ = inner.exit_tx.send(true);
570}
571
572fn read_ready(inner: &Arc<Inner>, pty: &mut tty::Pty, parser: &mut Processor, buf: &mut [u8]) {
575 loop {
576 match pty.reader().read(buf) {
577 Ok(0) => break,
578 Ok(n) => {
579 let bytes = &buf[..n];
580 inner.osc.lock().feed(bytes);
581 let seq = {
586 let mut term = inner.term.lock();
587 let mut appended = inner.lines.load(Ordering::Relaxed);
588 parser::advance(parser, &mut term, bytes, inner.history, &mut appended);
589 inner.lines.store(appended, Ordering::Relaxed);
590 inner.seq.fetch_add(1, Ordering::Relaxed)
591 };
592 let _ = inner.output.send(OutputChunk { seq, bytes: bytes.to_vec() });
593 *inner.last_output.lock() = Instant::now();
594 drain_responses(inner, pty);
595 }
596 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break,
597 Err(error) => {
598 tracing::debug!(
599 event_name = "terminal_read_failed",
600 pid = inner.child_pid,
601 %error,
602 "the terminal's read failed; the terminal ends"
603 );
604 break;
605 }
606 }
607 }
608 inner.publish_state();
609}
610
611fn drain_responses(inner: &Arc<Inner>, pty: &mut tty::Pty) {
614 let events: Vec<Event> = {
615 let mut events = inner.events.lock();
616 std::mem::take(&mut *events)
617 };
618 for event in events {
619 match event {
620 Event::PtyWrite(text) => {
621 let _ = pty.writer().write_all(text.as_bytes());
622 }
623 Event::Title(title) => *inner.title.lock() = title,
624 Event::ResetTitle => inner.title.lock().clear(),
625 _ => {}
626 }
627 }
628}
629
630fn poll_ended(flags: PollFlags) -> bool {
633 flags.intersects(PollFlags::HUP | PollFlags::ERR)
634}
635
636fn last_nonempty_line<T: EventListener>(term: &Term<T>) -> String {
638 let grid = term.grid();
639 for row in (0..grid.screen_lines()).rev() {
640 let line = alacritty_terminal::index::Line(i32::try_from(row).unwrap_or(i32::MAX));
641 let text: String = (&grid[line]).into_iter().map(|cell| cell.c).collect();
642 let trimmed = text.trim_end();
643 if !trimmed.is_empty() {
644 return trimmed.to_owned();
645 }
646 }
647 String::new()
648}
649
650fn exit_signal(status: std::process::ExitStatus) -> Option<i32> {
652 #[cfg(unix)]
653 {
654 use std::os::unix::process::ExitStatusExt;
655 status.signal()
656 }
657 #[cfg(not(unix))]
658 {
659 let _ = status;
660 None
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 fn config(shell: &str) -> TerminalConfig {
669 let mut env = HashMap::new();
670 env.insert("HOME".to_owned(), std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_owned()));
671 env.insert("PATH".to_owned(), std::env::var("PATH").unwrap_or_default());
672 TerminalConfig {
673 shell: shell.to_owned(),
674 cwd: std::env::temp_dir(),
675 env,
676 cols: 80,
677 rows: 24,
678 scrollback_lines: 2000,
679 }
680 }
681
682 #[test]
685 fn a_shell_runs_a_command_and_the_screen_carries_its_output() {
686 let terminal = Terminal::spawn(&config("/bin/sh")).expect("a terminal");
687 let deadline = Instant::now() + Duration::from_secs(5);
689 while Instant::now() < deadline {
690 if !terminal.screen().text.trim().is_empty() {
691 break;
692 }
693 std::thread::sleep(Duration::from_millis(20));
694 }
695 terminal.write_text("echo FORGE_TERMINAL_MARKER_$((6*7))\r").expect("a write");
696 let deadline = Instant::now() + Duration::from_secs(5);
697 let mut found = false;
698 while Instant::now() < deadline {
699 if terminal.screen().text.contains("FORGE_TERMINAL_MARKER_42") {
700 found = true;
701 break;
702 }
703 std::thread::sleep(Duration::from_millis(20));
704 }
705 assert!(found, "the command's output reached the screen: {:?}", terminal.screen().text);
706 terminal.close();
707 let deadline = Instant::now() + Duration::from_secs(5);
708 while Instant::now() < deadline && terminal.is_alive() {
709 std::thread::sleep(Duration::from_millis(20));
710 }
711 assert!(!terminal.is_alive(), "the shell ended");
712 }
713
714 #[test]
717 fn the_output_stream_carries_the_bytes_with_a_sequence() {
718 let terminal = Terminal::spawn(&config("/bin/sh")).expect("a terminal");
719 let mut output = terminal.subscribe();
720 terminal.write_text("echo STREAM_MARKER\r").expect("a write");
721 let deadline = Instant::now() + Duration::from_secs(5);
722 let mut seen = Vec::new();
723 while Instant::now() < deadline {
724 match output.try_recv() {
725 Ok(chunk) => {
726 seen.push(chunk.seq);
727 if String::from_utf8_lossy(&chunk.bytes).contains("STREAM_MARKER") {
728 break;
729 }
730 }
731 Err(broadcast::error::TryRecvError::Empty) => {
732 std::thread::sleep(Duration::from_millis(10));
733 }
734 Err(_) => break,
735 }
736 }
737 assert!(!seen.is_empty(), "the stream carried the shell's output");
738 assert!(seen.windows(2).all(|pair| pair[0] < pair[1]), "the sequence is monotonic");
739 terminal.close();
740 }
741
742 #[test]
744 fn a_resize_reaches_the_model() {
745 let terminal = Terminal::spawn(&config("/bin/sh")).expect("a terminal");
746 terminal.resize(100, 30).expect("a resize");
747 let deadline = Instant::now() + Duration::from_secs(2);
748 while Instant::now() < deadline && terminal.cols() != 100 {
749 std::thread::sleep(Duration::from_millis(10));
750 }
751 assert_eq!(terminal.cols(), 100);
752 assert_eq!(terminal.rows(), 30);
753 terminal.close();
754 }
755
756 #[test]
759 fn closing_a_terminal_kills_its_process_group() {
760 let terminal = Terminal::spawn(&config("/bin/sh")).expect("a terminal");
761 let deadline = Instant::now() + Duration::from_secs(5);
762 while Instant::now() < deadline && terminal.screen().text.trim().is_empty() {
763 std::thread::sleep(Duration::from_millis(20));
764 }
765 terminal.write_text("echo PID=$$\r").expect("a write");
766 let deadline = Instant::now() + Duration::from_secs(5);
767 let mut pid = None;
768 while Instant::now() < deadline && pid.is_none() {
769 let screen = terminal.screen().text;
770 pid = screen
771 .split_whitespace()
772 .filter_map(|word| word.strip_prefix("PID="))
773 .find_map(|pid| pid.parse::<i32>().ok());
774 if pid.is_none() {
775 std::thread::sleep(Duration::from_millis(20));
776 }
777 }
778 let Some(pid) = pid else {
779 panic!("the shell printed its pid: {:?}", terminal.screen().text);
780 };
781 terminal.write_text("trap \"\" HUP; exec sleep 300\r").expect("a write");
785 std::thread::sleep(Duration::from_millis(200));
786 terminal.close();
787 let deadline = Instant::now() + Duration::from_secs(5);
788 let mut gone = false;
789 while Instant::now() < deadline && !gone {
790 if let Some(pid) = Pid::from_raw(pid) {
791 gone = rustix::process::test_kill_process(pid).is_err();
792 }
793 if !gone {
794 std::thread::sleep(Duration::from_millis(20));
795 }
796 }
797 if !gone {
798 if let Some(pid) = Pid::from_raw(pid) {
801 let _ = rustix::process::kill_process(pid, Signal::KILL);
802 }
803 }
804 assert!(gone, "the terminal's process group was killed");
805 }
806
807 #[test]
811 fn a_cwd_that_cannot_be_entered_is_refused() {
812 let mut bad = config("/bin/sh");
813 bad.cwd = PathBuf::from("/definitely/not/a/forge/terminal/cwd");
814 let Err(error) = Terminal::spawn(&bad) else {
815 panic!("the cwd is refused");
816 };
817 assert!(
818 error.to_string().contains("/definitely/not/a/forge/terminal/cwd"),
819 "the error names the path: {error}"
820 );
821 }
822
823 #[test]
826 fn a_dead_shell_reports_its_own_exit() {
827 let terminal = Terminal::spawn(&config("/bin/sh")).expect("a terminal");
828 let deadline = Instant::now() + Duration::from_secs(5);
829 while Instant::now() < deadline && terminal.screen().text.trim().is_empty() {
830 std::thread::sleep(Duration::from_millis(20));
831 }
832 terminal.write_text("exit 3\r").expect("a write");
833 let deadline = Instant::now() + Duration::from_secs(5);
834 while Instant::now() < deadline && terminal.is_alive() {
835 std::thread::sleep(Duration::from_millis(20));
836 }
837 let state = terminal.state();
838 assert!(!state.running, "the shell is gone: {state:?}");
839 assert!(state.input_expected.is_none(), "nothing is waiting for input: {state:?}");
840 assert_eq!(state.exit, Some(Exit { code: Some(3), signal: None }), "{state:?}");
841 }
842
843 #[test]
847 fn a_hung_up_or_errored_pty_ends_the_loop() {
848 assert!(poll_ended(PollFlags::HUP), "a hangup has nothing left to read");
849 assert!(poll_ended(PollFlags::ERR), "an error is not a retry");
850 assert!(!poll_ended(PollFlags::IN), "a readable terminal is not over");
851 }
852
853 #[test]
855 fn a_closed_terminal_refuses_input() {
856 let terminal = Terminal::spawn(&config("/bin/sh")).expect("a terminal");
857 terminal.close();
858 let deadline = Instant::now() + Duration::from_secs(5);
859 while Instant::now() < deadline && terminal.is_alive() {
860 std::thread::sleep(Duration::from_millis(20));
861 }
862 assert!(matches!(terminal.write_text("x"), Err(Error::Closed)));
863 }
864}