Skip to main content

trail/terminal/
session.rs

1//! One shell in the terminal panel: a pseudo-terminal, the process in it, and
2//! the screen its output has drawn.
3//!
4//! A session owns three threads, none of which touch the UI:
5//!
6//! - **reader** — reads the shell's output, feeds it to the `vt100` parser, and
7//!   tells the UI thread there is something new to draw;
8//! - **writer** — writes keystrokes to the shell, so a shell that stops reading
9//!   input can never stall the UI thread;
10//! - **waiter** — waits for the shell to exit and reports it. On Windows the
11//!   reader never sees end-of-file while the pseudo-console is open, so the
12//!   reader cannot be the one to notice.
13//!
14//! They are plain threads rather than tokio tasks because every call they make
15//! blocks, which is exactly what a tokio worker must not do.
16
17use std::io::{Read, Write};
18use std::path::Path;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::{mpsc as std_mpsc, Arc, Mutex, MutexGuard};
21
22use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize};
23use tokio::sync::mpsc;
24
25use crate::workers::WorkerMsg;
26
27/// Rows of output a session keeps above the visible screen.
28///
29/// Bounded because it is memory per shell: a row is a few bytes per column, so
30/// 2 000 rows of a 200-column panel is a few megabytes, not a few hundred.
31pub const SCROLLBACK_ROWS: usize = 2_000;
32
33/// A shell running in the panel.
34pub struct Session {
35    id: u64,
36    label: String,
37    pid: Option<u32>,
38    parser: Arc<Mutex<vt100::Parser<Replies>>>,
39    /// Set by the reader when it has told the UI about new output, cleared by
40    /// the UI once that output is on screen. Coalesces a flood of output into
41    /// one message per frame instead of one per read.
42    output_signalled: Arc<AtomicBool>,
43    input: std_mpsc::Sender<Vec<u8>>,
44    killer: Box<dyn ChildKiller + Send + Sync>,
45    /// `Option` only so `Drop` can move it to another thread.
46    master: Option<Box<dyn MasterPty + Send>>,
47    size: (u16, u16),
48}
49
50impl std::fmt::Debug for Session {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("Session")
53            .field("id", &self.id)
54            .field("label", &self.label)
55            .field("pid", &self.pid)
56            .field("size", &self.size)
57            .finish_non_exhaustive()
58    }
59}
60
61impl Session {
62    /// Starts `argv` in `cwd` inside a pseudo-terminal of `rows` × `cols`.
63    ///
64    /// Output and exit are reported on `notify` as [`WorkerMsg::TerminalOutput`]
65    /// and [`WorkerMsg::TerminalExited`], tagged with `id`.
66    ///
67    /// # Errors
68    ///
69    /// Returns a description of what failed — the pseudo-terminal could not be
70    /// created, or the program could not be started (most often: not found).
71    pub fn spawn(
72        id: u64,
73        label: &str,
74        argv: &[String],
75        cwd: &Path,
76        (rows, cols): (u16, u16),
77        notify: mpsc::Sender<WorkerMsg>,
78    ) -> Result<Self, String> {
79        let (program, args) = argv
80            .split_first()
81            .ok_or_else(|| "the profile has no command".to_owned())?;
82        let rows = rows.max(1);
83        let cols = cols.max(1);
84
85        let pair = portable_pty::native_pty_system()
86            .openpty(PtySize {
87                rows,
88                cols,
89                pixel_width: 0,
90                pixel_height: 0,
91            })
92            .map_err(|e| format!("could not create a pseudo-terminal: {e}"))?;
93
94        let mut cmd = CommandBuilder::new(program);
95        cmd.args(args);
96        cmd.cwd(cwd);
97        // What the screen parser understands. Windows shells ignore it; Git
98        // Bash and WSL read it to decide which escape sequences to send.
99        cmd.env("TERM", "xterm-256color");
100
101        let mut child = pair
102            .slave
103            .spawn_command(cmd)
104            .map_err(|e| format!("could not start '{program}': {e}"))?;
105        // The slave end belongs to the child now; holding it would keep the
106        // pseudo-terminal alive after the shell exits on Unix.
107        drop(pair.slave);
108
109        let pid = child.process_id();
110        let killer = child.clone_killer();
111        let reader = pair
112            .master
113            .try_clone_reader()
114            .map_err(|e| format!("could not read from the pseudo-terminal: {e}"))?;
115        let writer = pair
116            .master
117            .take_writer()
118            .map_err(|e| format!("could not write to the pseudo-terminal: {e}"))?;
119
120        let parser = Arc::new(Mutex::new(vt100::Parser::new_with_callbacks(
121            rows,
122            cols,
123            SCROLLBACK_ROWS,
124            Replies::default(),
125        )));
126        let output_signalled = Arc::new(AtomicBool::new(false));
127        let (input, input_rx) = std_mpsc::channel::<Vec<u8>>();
128
129        spawn_writer(writer, input_rx);
130        spawn_reader(
131            id,
132            reader,
133            Arc::clone(&parser),
134            Arc::clone(&output_signalled),
135            input.clone(),
136            notify.clone(),
137        );
138        std::thread::Builder::new()
139            .name(format!("trail-term-wait-{id}"))
140            .spawn(move || {
141                let code = child.wait().ok().map(|status| status.exit_code());
142                // The UI may already be gone on quit; nothing to tell then.
143                let _ = notify.blocking_send(WorkerMsg::TerminalExited { id, code });
144            })
145            .map_err(|e| format!("could not start the exit watcher: {e}"))?;
146
147        Ok(Self {
148            id,
149            label: label.to_owned(),
150            pid,
151            parser,
152            output_signalled,
153            input,
154            killer,
155            master: Some(pair.master),
156            size: (rows, cols),
157        })
158    }
159
160    /// The identifier output and exit messages carry.
161    #[must_use]
162    pub fn id(&self) -> u64 {
163        self.id
164    }
165
166    /// The profile name the session was started from.
167    #[must_use]
168    pub fn label(&self) -> &str {
169        &self.label
170    }
171
172    /// The shell's process id, when the platform reported one.
173    #[must_use]
174    pub fn pid(&self) -> Option<u32> {
175        self.pid
176    }
177
178    /// Queues `bytes` for the shell. Never blocks.
179    pub fn write(&self, bytes: Vec<u8>) {
180        // A closed channel means the writer thread is gone because the shell
181        // is; the exit message is already on its way.
182        let _ = self.input.send(bytes);
183    }
184
185    /// Locks the screen for reading or for a scroll change.
186    ///
187    /// Held only for as long as one frame takes to copy cells out, so the
188    /// reader thread is never kept waiting for longer than that.
189    pub fn screen(&self) -> MutexGuard<'_, vt100::Parser<Replies>> {
190        self.parser.lock().unwrap_or_else(|poisoned| {
191            // A reader thread that panicked mid-parse leaves a screen that is
192            // at worst partly drawn, which is still worth showing.
193            poisoned.into_inner()
194        })
195    }
196
197    /// Lets the reader announce new output again, once the last batch is on
198    /// screen.
199    pub fn output_drawn(&self) {
200        self.output_signalled.store(false, Ordering::Release);
201    }
202
203    /// Resizes the pseudo-terminal and the screen to `rows` × `cols`, if they
204    /// changed. Cheap enough to call every frame.
205    pub fn resize(&mut self, (rows, cols): (u16, u16)) {
206        let size = (rows.max(1), cols.max(1));
207        if size == self.size {
208            return;
209        }
210        self.size = size;
211        if let Some(master) = &self.master {
212            if let Err(e) = master.resize(PtySize {
213                rows: size.0,
214                cols: size.1,
215                pixel_width: 0,
216                pixel_height: 0,
217            }) {
218                tracing::debug!("terminal resize failed: {e}");
219            }
220        }
221        self.screen().screen_mut().set_size(size.0, size.1);
222    }
223
224    /// Scrolls the view `delta` rows into the scrollback (positive is back in
225    /// time), clamped to what the scrollback holds.
226    pub fn scroll(&self, delta: isize) {
227        let mut parser = self.screen();
228        let screen = parser.screen_mut();
229        let target = screen.scrollback().saturating_add_signed(delta);
230        screen.set_scrollback(target);
231    }
232
233    /// Returns the view to the live screen.
234    pub fn scroll_to_bottom(&self) {
235        self.screen().screen_mut().set_scrollback(0);
236    }
237}
238
239impl Drop for Session {
240    fn drop(&mut self) {
241        if let Err(e) = self.killer.kill() {
242            // Already exited is the common case.
243            tracing::debug!("terminal session {} kill: {e}", self.id);
244        }
245        // Closing a Windows pseudo-console waits for its output to drain, so it
246        // is done off the UI thread. The reader is still draining it.
247        if let Some(master) = self.master.take() {
248            let spawned = std::thread::Builder::new()
249                .name("trail-term-close".to_owned())
250                .spawn(move || drop(master));
251            if let Err(e) = spawned {
252                tracing::debug!("could not close the pseudo-terminal off-thread: {e}");
253            }
254        }
255    }
256}
257
258fn spawn_writer(mut writer: Box<dyn Write + Send>, input: std_mpsc::Receiver<Vec<u8>>) {
259    let spawned = std::thread::Builder::new()
260        .name("trail-term-write".to_owned())
261        .spawn(move || {
262            for bytes in input {
263                if writer
264                    .write_all(&bytes)
265                    .and_then(|()| writer.flush())
266                    .is_err()
267                {
268                    break;
269                }
270            }
271        });
272    if let Err(e) = spawned {
273        tracing::warn!("could not start the terminal writer: {e}");
274    }
275}
276
277fn spawn_reader(
278    id: u64,
279    mut reader: Box<dyn Read + Send>,
280    parser: Arc<Mutex<vt100::Parser<Replies>>>,
281    signalled: Arc<AtomicBool>,
282    input: std_mpsc::Sender<Vec<u8>>,
283    notify: mpsc::Sender<WorkerMsg>,
284) {
285    let spawned = std::thread::Builder::new()
286        .name(format!("trail-term-read-{id}"))
287        .spawn(move || {
288            let mut buf = [0u8; 16 * 1024];
289            loop {
290                let n = match reader.read(&mut buf) {
291                    Ok(0) | Err(_) => break,
292                    Ok(n) => n,
293                };
294                let replies = {
295                    let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
296                    parser.process(&buf[..n]);
297                    std::mem::take(&mut parser.callbacks_mut().pending)
298                };
299                if !replies.is_empty() {
300                    let _ = input.send(replies);
301                }
302                if !signalled.swap(true, Ordering::AcqRel)
303                    && notify.try_send(WorkerMsg::TerminalOutput { id }).is_err()
304                {
305                    // The channel is full, so the UI is busy and will draw
306                    // soon anyway; let the next read signal again.
307                    signalled.store(false, Ordering::Release);
308                }
309            }
310        });
311    if let Err(e) = spawned {
312        tracing::warn!("could not start the terminal reader: {e}");
313    }
314}
315
316/// Answers the questions a program asks its terminal.
317///
318/// A real terminal replies to "where is the cursor?" and "what are you?";
319/// programs that ask (prompt frameworks, `vim`, some installers) wait for the
320/// answer, and without one they stall for seconds or draw in the wrong place.
321#[derive(Debug, Default)]
322pub struct Replies {
323    pending: Vec<u8>,
324}
325
326impl vt100::Callbacks for Replies {
327    fn unhandled_csi(
328        &mut self,
329        screen: &mut vt100::Screen,
330        i1: Option<u8>,
331        _i2: Option<u8>,
332        params: &[&[u16]],
333        c: char,
334    ) {
335        let first = params.first().and_then(|p| p.first()).copied();
336        match (c, i1, first) {
337            // DSR: cursor position report.
338            ('n', None, Some(6)) => {
339                let (row, col) = screen.cursor_position();
340                self.pending
341                    .extend(format!("\x1b[{};{}R", row + 1, col + 1).into_bytes());
342            }
343            // DSR: "are you working?" — yes.
344            ('n', None, Some(5)) => self.pending.extend(b"\x1b[0n"),
345            // DA1: identify as a VT220-class terminal, which is what the
346            // parser implements.
347            ('c', None, None | Some(0)) => self.pending.extend(b"\x1b[?62;22c"),
348            _ => {}
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn the_parser_answers_cursor_and_device_queries() {
359        let mut parser = vt100::Parser::new_with_callbacks(5, 20, 0, Replies::default());
360        parser.process(b"ab\x1b[6n");
361        assert_eq!(parser.callbacks().pending, b"\x1b[1;3R");
362
363        parser.callbacks_mut().pending.clear();
364        parser.process(b"\x1b[c\x1b[5n");
365        assert_eq!(parser.callbacks().pending, b"\x1b[?62;22c\x1b[0n");
366    }
367
368    /// The whole lifecycle against a real shell: start, type, read the output
369    /// back from the screen, and see the exit reported.
370    #[test]
371    fn a_real_shell_runs_a_command_and_reports_its_exit() {
372        let (tx, mut rx) = mpsc::channel(64);
373        let dir = tempfile::tempdir().unwrap();
374        let argv: Vec<String> = if cfg!(windows) {
375            vec!["cmd.exe".into(), "/Q".into(), "/K".into()]
376        } else {
377            vec!["/bin/sh".into()]
378        };
379        let session = Session::spawn(1, "test", &argv, dir.path(), (10, 60), tx).unwrap();
380        session.write(b"echo trail-marker-42\r".to_vec());
381
382        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
383        loop {
384            assert!(std::time::Instant::now() < deadline, "no output seen");
385            if session
386                .screen()
387                .screen()
388                .contents()
389                .contains("trail-marker-42\n")
390                || session
391                    .screen()
392                    .screen()
393                    .contents()
394                    .matches("trail-marker-42")
395                    .count()
396                    >= 2
397            {
398                break;
399            }
400            std::thread::sleep(std::time::Duration::from_millis(50));
401        }
402
403        session.write(b"exit\r".to_vec());
404        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
405        loop {
406            assert!(std::time::Instant::now() < deadline, "no exit seen");
407            match rx.try_recv() {
408                Ok(WorkerMsg::TerminalExited { id, .. }) => {
409                    assert_eq!(id, 1);
410                    break;
411                }
412                Ok(_) | Err(mpsc::error::TryRecvError::Empty) => {
413                    std::thread::sleep(std::time::Duration::from_millis(50));
414                }
415                Err(e) => panic!("channel closed: {e}"),
416            }
417        }
418    }
419}