Skip to main content

forge_terminal/
terminal.rs

1//! One terminal: the PTY, the screen model, the read loop and the small
2//! interface a seat drives.
3//!
4//! **The event loop is forge's own.** `alacritty_terminal`'s `EventLoop`
5//! reads the PTY internally and only reports `Wakeup`, so the raw bytes never
6//! reach a listener - and the client's stream is exactly those bytes. This
7//! module runs the same loop with the tap: poll, read, scan for OSC 133, send
8//! the bytes on, parse them into `Term`, and write the terminal's own
9//! responses back.
10//!
11//! **One thread per terminal.** The read loop owns the `Pty`; writes go
12//! through a cloned master fd, and a resize is a message to the loop because
13//! `TIOCSWINSZ` is the `Pty`'s to make.
14
15use 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
39/// How long the screen must be quiet before the fallback reads it as a
40/// prompt.
41const QUIET: Duration = Duration::from_millis(150);
42
43/// The most output one broadcast keeps for a viewer that is not reading.
44const OUTPUT_BACKLOG: usize = 256;
45
46/// How many times a nonblocking write retries before it gives up.
47const WRITE_RETRIES: u32 = 100;
48
49/// The smallest and largest size a terminal may take. Zero columns underflows
50/// alacritty's grid arithmetic.
51pub 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/// What a terminal is spawned with.
57#[derive(Clone, Debug)]
58pub struct TerminalConfig {
59    /// The shell to run, e.g. `/bin/zsh`.
60    pub shell: String,
61    pub cwd: PathBuf,
62    /// The seat's environment, exactly what its `claude` child gets.
63    pub env: HashMap<String, String>,
64    pub cols: u16,
65    pub rows: u16,
66    pub scrollback_lines: usize,
67}
68
69/// One chunk of the terminal's output, with its per-terminal sequence.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct OutputChunk {
72    pub seq: u32,
73    pub bytes: Vec<u8>,
74}
75
76/// Why a terminal could not be spawned or driven.
77#[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
92/// One terminal.
93pub struct Terminal {
94    inner: Arc<Inner>,
95    thread: Mutex<Option<std::thread::JoinHandle<()>>>,
96}
97
98impl Terminal {
99    /// Spawn a shell on a fresh PTY.
100    pub fn spawn(config: &TerminalConfig) -> Result<Self, Error> {
101        // **A cwd the PTY cannot enter is refused, not ignored.** The child's
102        // chdir failure is silent, which would run the shell in whatever
103        // directory forge was launched from.
104        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            // Chunks start at 1: a viewer's repaint sequence is `next - 1`,
169            // so an unparsed terminal reports 0 and the first chunk still
170            // lies above it.
171            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    /// Type text as-is.
209    pub fn write_text(&self, text: &str) -> Result<(), Error> {
210        self.inner.write(text.as_bytes())
211    }
212
213    /// Type raw bytes as-is: a client's own keystrokes, which are already the
214    /// bytes the PTY wants.
215    pub fn write_bytes(&self, bytes: &[u8]) -> Result<(), Error> {
216        self.inner.write(bytes)
217    }
218
219    /// Press named keys, in order.
220    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    /// Resize the PTY and the model.
228    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            // **The model resizes here, not on the read loop.** A repaint
237            // taken right after this call must already be laid out at the
238            // new size, and the read cursor moves with the reflow.
239            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    /// End the terminal: kill the process group and let the loop finish.
251    pub fn close(&self) {
252        self.inner.close();
253    }
254
255    /// The visible screen as text.
256    pub fn screen(&self) -> Screen {
257        let term = self.inner.term.lock();
258        screen::extract(&term)
259    }
260
261    /// A bounded read of the scrollback and the screen.
262    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    /// The visible screen as a VT stream, with the sequence the next chunk
269    /// will carry - both read under the term's lock, because the sequence is
270    /// bumped under it after each parse. So every chunk below the sequence is
271    /// already in the repaint, and no chunk at or above it is.
272    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    /// What the terminal is doing.
278    pub fn state(&self) -> State {
279        self.inner.state()
280    }
281
282    /// The output stream, from the next chunk.
283    pub fn subscribe(&self) -> broadcast::Receiver<OutputChunk> {
284        self.inner.output.subscribe()
285    }
286
287    /// The state stream, from the current value.
288    pub fn state_stream(&self) -> watch::Receiver<State> {
289        self.inner.state_rx.clone()
290    }
291
292    /// A stream that fires once, when the shell exits.
293    pub fn exit_stream(&self) -> watch::Receiver<bool> {
294        self.inner.exit_rx.clone()
295    }
296
297    /// Whether the shell process is alive.
298    pub fn is_alive(&self) -> bool {
299        self.inner.alive.load(Ordering::Relaxed)
300    }
301
302    /// The shell's own exit, once it has one.
303    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    /// The title the shell last set, when it set one.
324    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/// The listener `Term` reports its own responses through.
342#[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    /// The lifetime count of lines that entered the buffer, read cursors
383    /// measured in it. Written under the term lock, so it is consistent with
384    /// the grid a read sees.
385    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        // A partial write has already put its prefix on the wire, so the
408        // retry resumes at the offset rather than repeating it.
409        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        // **Only a real transition notifies.** Every read batch republishes,
445        // and a watcher that announced each one would push a frame per chunk.
446        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    /// **A dead shell is not running anything.** Its marks still hold the last
456    /// command, so the classifier alone would report a running session whose
457    /// last exit is stale; the shell's own exit is the honest answer.
458    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
480/// The read loop: poll, read, tap, parse, respond, and notice the child.
481fn 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        // **The child's own exit is read before a hangup ends the loop**, or
523        // the shell's code would be lost to the same poll that reports it.
524        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            // **Nothing is left to read.** The slave side is gone, so polling
538            // would spin on the hung-up fd; end the terminal and kill what
539            // still ran on it.
540            inner.close();
541            // **The hangup is how a shell's exit first shows on Linux.** The
542            // SIGCHLD can land a moment after the hangup, so the reap is
543            // waited for and the shell's own code is kept; a child the kill
544            // ended reports its signal instead.
545            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
572/// Read everything the PTY has, tap it, parse it, and answer the terminal's
573/// own queries.
574fn 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                // **Parse and sequence under one lock.** A reader that sees
582                // sequence N can repaint and know every chunk below N is in
583                // the screen; a sequence bumped before the parse would let a
584                // chunk be skipped as "already drawn" while it was not.
585                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
611/// Write the terminal's own responses (a device-attributes answer, a colour
612/// report) back to the PTY, and keep the title the shell set.
613fn 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
630/// Whether a poll's flags say the terminal is over: the slave side is gone or
631/// the fd errored, so no byte can arrive again.
632fn poll_ended(flags: PollFlags) -> bool {
633    flags.intersects(PollFlags::HUP | PollFlags::ERR)
634}
635
636/// The last non-empty line on the visible screen.
637fn 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
650/// The signal a process died from, when it did.
651fn 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    /// **The whole loop, end to end.** A shell is spawned, a command typed,
683    /// its output read off the screen, and the terminal closed cleanly.
684    #[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        // Wait for the shell to be ready, then type a command.
688        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    /// The output stream carries the bytes the screen was built from, with a
715    /// monotonic sequence.
716    #[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    /// A resize reaches the PTY and the model, and the model reflows.
743    #[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    /// Closing kills the terminal's process group, not just the PTY: a child
757    /// that never reads the terminal is still gone.
758    #[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        // `exec` keeps the pid and the process group, so the sleep stands in
782        // for the shell without touching the terminal; the ignored HUP
783        // outlives exec, so closing the PTY alone does not end it.
784        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            // The PTY's own drop waits for the child, so a child this close
799            // failed to reach would hang the assertion instead of failing it.
800            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    /// **A cwd the PTY cannot enter is refused by name.** alacritty ignores
808    /// the child's chdir failure, which would run the shell in forge's own
809    /// launch directory.
810    #[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    /// **A dead shell is not running and reports its own exit**, not the last
824    /// command's marks.
825    #[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    /// **A hung-up or errored PTY is over.** Polling one again would return
844    /// the same flag forever, which was a full-CPU spin reporting the shell
845    /// alive.
846    #[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    /// A closed terminal refuses input rather than writing into a dead PTY.
854    #[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}