Skip to main content

harn_terminal/
session.rs

1use std::collections::VecDeque;
2use std::io::{Read, Write};
3use std::sync::{Arc, Condvar, Mutex, MutexGuard};
4use std::thread::{self, JoinHandle};
5use std::time::{Duration, Instant};
6
7use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
8
9use crate::input::{encode_events, InputEvent};
10use crate::types::{
11    validate_dimensions, CellCapture, CellRegion, ProcessStatus, ScreenCapture, SessionOptions,
12    TerminalError, WaitIdleResult,
13};
14
15#[derive(Default)]
16struct ParserTelemetry {
17    errors: usize,
18}
19
20impl ParserTelemetry {
21    fn record_error(&mut self) {
22        self.errors = self.errors.saturating_add(1);
23    }
24}
25
26impl vt100::Callbacks for ParserTelemetry {
27    fn unhandled_char(&mut self, _: &mut vt100::Screen, _: char) {
28        self.record_error();
29    }
30
31    fn unhandled_control(&mut self, _: &mut vt100::Screen, _: u8) {
32        self.record_error();
33    }
34}
35
36struct TerminalState {
37    parser: vt100::Parser<ParserTelemetry>,
38    raw: VecDeque<u8>,
39    raw_capacity: usize,
40    raw_truncated: bool,
41    bytes_received: u64,
42    revision: u64,
43    last_output_at: Instant,
44    status: ProcessStatus,
45    reader_error: Option<String>,
46    reader_done: bool,
47}
48
49struct SharedState {
50    state: Mutex<TerminalState>,
51    changed: Condvar,
52}
53
54/// A live pseudo-terminal session with typed VT state.
55pub struct TerminalSession {
56    shared: Arc<SharedState>,
57    writer: Mutex<Option<Box<dyn Write + Send>>>,
58    master: Mutex<Option<Box<dyn MasterPty + Send>>>,
59    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
60    reader: Mutex<Option<JoinHandle<()>>>,
61    waiter: Mutex<Option<JoinHandle<()>>>,
62}
63
64impl TerminalSession {
65    /// Start a child process in a fresh native PTY.
66    pub fn spawn(options: SessionOptions) -> Result<Self, TerminalError> {
67        options.validate()?;
68        let size = pty_size(options.rows, options.cols);
69        let pair = native_pty_system()
70            .openpty(size)
71            .map_err(|error| TerminalError::OpenPty(error.to_string()))?;
72
73        let mut command = CommandBuilder::new(&options.argv[0]);
74        command.args(&options.argv[1..]);
75        if let Some(cwd) = options.cwd.as_ref() {
76            command.cwd(cwd);
77        }
78        for key in &options.env_remove {
79            command.env_remove(key);
80        }
81        for (key, value) in &options.env {
82            command.env(key, value);
83        }
84
85        let mut child = pair
86            .slave
87            .spawn_command(command)
88            .map_err(|error| TerminalError::Spawn(error.to_string()))?;
89        let killer = child.clone_killer();
90        drop(pair.slave);
91
92        let writer = match pair.master.take_writer() {
93            Ok(writer) => writer,
94            Err(error) => {
95                let _ = child.kill();
96                let _ = child.wait();
97                return Err(TerminalError::Io(error.to_string()));
98            }
99        };
100        let mut reader = match pair.master.try_clone_reader() {
101            Ok(reader) => reader,
102            Err(error) => {
103                let _ = child.kill();
104                let _ = child.wait();
105                return Err(TerminalError::Io(error.to_string()));
106            }
107        };
108
109        let now = Instant::now();
110        let shared = Arc::new(SharedState {
111            state: Mutex::new(TerminalState {
112                parser: vt100::Parser::new_with_callbacks(
113                    options.rows,
114                    options.cols,
115                    0,
116                    ParserTelemetry::default(),
117                ),
118                raw: VecDeque::with_capacity(options.raw_capacity.min(16 * 1024)),
119                raw_capacity: options.raw_capacity,
120                raw_truncated: false,
121                bytes_received: 0,
122                revision: 0,
123                last_output_at: now,
124                status: ProcessStatus::Running,
125                reader_error: None,
126                reader_done: false,
127            }),
128            changed: Condvar::new(),
129        });
130
131        let reader_shared = Arc::clone(&shared);
132        let reader_handle = match thread::Builder::new()
133            .name("harn-terminal-reader".to_string())
134            .spawn(move || {
135                let mut buffer = [0_u8; 8192];
136                loop {
137                    match reader.read(&mut buffer) {
138                        Ok(0) => break,
139                        Ok(count) => {
140                            let Ok(mut state) = reader_shared.state.lock() else {
141                                break;
142                            };
143                            append_raw(&mut state, &buffer[..count]);
144                            state.parser.process(&buffer[..count]);
145                            state.bytes_received = state
146                                .bytes_received
147                                .saturating_add(u64::try_from(count).unwrap_or(u64::MAX));
148                            state.revision = state.revision.saturating_add(1);
149                            state.last_output_at = Instant::now();
150                            drop(state);
151                            reader_shared.changed.notify_all();
152                        }
153                        Err(error) => {
154                            if let Ok(mut state) = reader_shared.state.lock() {
155                                if !is_normal_pty_eof(&error) {
156                                    state.reader_error = Some(error.to_string());
157                                }
158                                state.revision = state.revision.saturating_add(1);
159                            }
160                            break;
161                        }
162                    }
163                }
164                if let Ok(mut state) = reader_shared.state.lock() {
165                    state.reader_done = true;
166                }
167                reader_shared.changed.notify_all();
168            }) {
169            Ok(handle) => handle,
170            Err(error) => {
171                let _ = child.kill();
172                let _ = child.wait();
173                return Err(TerminalError::Io(error.to_string()));
174            }
175        };
176
177        let waiter_shared = Arc::clone(&shared);
178        let mut killer = killer;
179        let waiter_handle = match thread::Builder::new()
180            .name("harn-terminal-waiter".to_string())
181            .spawn(move || {
182                let status = match child.wait() {
183                    Ok(status) => {
184                        let signal = status.signal().map(ToOwned::to_owned);
185                        ProcessStatus::Exited {
186                            code: signal.is_none().then(|| status.exit_code()),
187                            signal,
188                        }
189                    }
190                    Err(error) => ProcessStatus::Failed {
191                        message: error.to_string(),
192                    },
193                };
194                if let Ok(mut state) = waiter_shared.state.lock() {
195                    state.status = status;
196                    state.revision = state.revision.saturating_add(1);
197                }
198                waiter_shared.changed.notify_all();
199            }) {
200            Ok(handle) => handle,
201            Err(error) => {
202                let _ = killer.kill();
203                drop(writer);
204                drop(pair.master);
205                let _ = reader_handle.join();
206                return Err(TerminalError::Io(error.to_string()));
207            }
208        };
209
210        Ok(Self {
211            shared,
212            writer: Mutex::new(Some(writer)),
213            master: Mutex::new(Some(pair.master)),
214            killer: Mutex::new(killer),
215            reader: Mutex::new(Some(reader_handle)),
216            waiter: Mutex::new(Some(waiter_handle)),
217        })
218    }
219
220    /// Send a prevalidated batch of text and typed key events.
221    pub fn send(&self, events: &[InputEvent]) -> Result<usize, TerminalError> {
222        let bytes = encode_events(events)?;
223        let mut writer = lock(&self.writer)?;
224        let writer = writer.as_mut().ok_or(TerminalError::Closed)?;
225        writer
226            .write_all(&bytes)
227            .and_then(|()| writer.flush())
228            .map_err(|error| TerminalError::Io(error.to_string()))?;
229        Ok(bytes.len())
230    }
231
232    /// Capture the current screen, optionally including detailed cells.
233    pub fn capture(&self, region: Option<CellRegion>) -> Result<ScreenCapture, TerminalError> {
234        let state = lock(&self.shared.state)?;
235        let screen = state.parser.screen();
236        let (rows, columns) = screen.size();
237        if let Some(region) = region {
238            region.validate(rows, columns)?;
239        }
240        let text_rows = screen.rows(0, columns).collect();
241        let (cursor_row, cursor_column) = screen.cursor_position();
242        let cells = region.map(|region| capture_cells(screen, region));
243        Ok(ScreenCapture {
244            schema_version: 1,
245            rows,
246            columns,
247            text_rows,
248            cursor_row,
249            cursor_column,
250            cursor_visible: !screen.hide_cursor(),
251            alternate_screen: screen.alternate_screen(),
252            revision: state.revision,
253            bytes_received: state.bytes_received,
254            raw_truncated: state.raw_truncated,
255            parser_errors: state.parser.callbacks().errors,
256            reader_error: state.reader_error.clone(),
257            status: state.status.clone(),
258            cells,
259        })
260    }
261
262    /// Resize both the native PTY and the VT parser.
263    pub fn resize(&self, rows: u16, columns: u16) -> Result<(), TerminalError> {
264        validate_dimensions(rows, columns)?;
265        let master = lock(&self.master)?;
266        master
267            .as_ref()
268            .ok_or(TerminalError::Closed)?
269            .resize(pty_size(rows, columns))
270            .map_err(|error| TerminalError::Io(error.to_string()))?;
271        drop(master);
272        let mut state = lock(&self.shared.state)?;
273        state.parser.screen_mut().set_size(rows, columns);
274        state.revision = state.revision.saturating_add(1);
275        drop(state);
276        self.shared.changed.notify_all();
277        Ok(())
278    }
279
280    /// Wait until no child output has arrived for `quiet`.
281    pub fn wait_idle(
282        &self,
283        quiet: Duration,
284        timeout: Duration,
285    ) -> Result<WaitIdleResult, TerminalError> {
286        self.wait_idle_inner(None, quiet, timeout)
287    }
288
289    /// Wait for a state revision newer than `after_revision`, then for quiet.
290    ///
291    /// This fences a send-then-capture sequence against output that was already
292    /// quiet before the input was written. The wait remains notification-driven.
293    pub fn wait_idle_after(
294        &self,
295        after_revision: u64,
296        quiet: Duration,
297        timeout: Duration,
298    ) -> Result<WaitIdleResult, TerminalError> {
299        self.wait_idle_inner(Some(after_revision), quiet, timeout)
300    }
301
302    fn wait_idle_inner(
303        &self,
304        after_revision: Option<u64>,
305        quiet: Duration,
306        timeout: Duration,
307    ) -> Result<WaitIdleResult, TerminalError> {
308        if quiet > timeout {
309            return Err(TerminalError::InvalidArgument(
310                "quiet duration must not exceed timeout".to_string(),
311            ));
312        }
313        let started = Instant::now();
314        let deadline = started + timeout;
315        let mut state = lock(&self.shared.state)?;
316        loop {
317            let now = Instant::now();
318            let quiet_for = now.saturating_duration_since(state.last_output_at);
319            let fully_exited = state.status.finished() && state.reader_done;
320            let observed_output = state.bytes_received > 0;
321            let crossed_fence = after_revision.is_none_or(|revision| state.revision > revision);
322            if fully_exited
323                || !state.status.finished()
324                    && observed_output
325                    && crossed_fence
326                    && quiet_for >= quiet
327            {
328                return Ok(WaitIdleResult {
329                    revision: state.revision,
330                    quiet_ms: duration_ms(quiet),
331                    status: state.status.clone(),
332                });
333            }
334            if now >= deadline {
335                return Err(TerminalError::Timeout {
336                    operation: "idle output",
337                    timeout_ms: duration_ms(timeout),
338                });
339            }
340            let remaining_quiet = quiet.saturating_sub(quiet_for);
341            let remaining_timeout = deadline.saturating_duration_since(now);
342            let wait_for = if state.status.finished() || !observed_output || !crossed_fence {
343                remaining_timeout
344            } else {
345                remaining_quiet.min(remaining_timeout)
346            };
347            let (next, _) = self
348                .shared
349                .changed
350                .wait_timeout(state, wait_for)
351                .map_err(|_| TerminalError::Poisoned)?;
352            state = next;
353        }
354    }
355
356    /// Wait for the child process to reach a terminal state.
357    pub fn wait_exit(&self, timeout: Duration) -> Result<ProcessStatus, TerminalError> {
358        let deadline = Instant::now() + timeout;
359        let mut state = lock(&self.shared.state)?;
360        loop {
361            if state.status.finished() && state.reader_done {
362                return Ok(state.status.clone());
363            }
364            let now = Instant::now();
365            if now >= deadline {
366                return Err(TerminalError::Timeout {
367                    operation: "process exit",
368                    timeout_ms: duration_ms(timeout),
369                });
370            }
371            let (next, _) = self
372                .shared
373                .changed
374                .wait_timeout(state, deadline.saturating_duration_since(now))
375                .map_err(|_| TerminalError::Poisoned)?;
376            state = next;
377        }
378    }
379
380    /// Terminate the child, close terminal handles, and join worker threads.
381    pub fn end(&self, timeout: Duration) -> Result<ProcessStatus, TerminalError> {
382        let running = !lock(&self.shared.state)?.status.finished();
383        let kill_error = if running {
384            lock(&self.killer)?.kill().err()
385        } else {
386            None
387        };
388        lock(&self.writer)?.take();
389        lock(&self.master)?.take();
390        let status = match self.wait_exit(timeout) {
391            Ok(status) => status,
392            Err(wait_error) => {
393                return Err(match kill_error {
394                    Some(kill_error) => TerminalError::Io(format!(
395                        "failed to terminate child ({kill_error}); {wait_error}"
396                    )),
397                    None => wait_error,
398                });
399            }
400        };
401        join_handle(&self.waiter)?;
402        join_handle(&self.reader)?;
403        Ok(status)
404    }
405}
406
407impl Drop for TerminalSession {
408    fn drop(&mut self) {
409        let _ = self.end(Duration::from_secs(2));
410    }
411}
412
413fn pty_size(rows: u16, cols: u16) -> PtySize {
414    PtySize {
415        rows,
416        cols,
417        pixel_width: 0,
418        pixel_height: 0,
419    }
420}
421
422fn is_normal_pty_eof(error: &std::io::Error) -> bool {
423    error.kind() == std::io::ErrorKind::BrokenPipe
424        || error.kind() == std::io::ErrorKind::UnexpectedEof
425        || cfg!(unix) && error.raw_os_error() == Some(5)
426}
427
428fn append_raw(state: &mut TerminalState, bytes: &[u8]) {
429    let overflow = state
430        .raw
431        .len()
432        .saturating_add(bytes.len())
433        .saturating_sub(state.raw_capacity);
434    if overflow > 0 {
435        let remove = overflow.min(state.raw.len());
436        state.raw.drain(..remove);
437        state.raw_truncated = true;
438    }
439    let start = bytes.len().saturating_sub(state.raw_capacity);
440    if start > 0 {
441        state.raw_truncated = true;
442    }
443    state.raw.extend(&bytes[start..]);
444}
445
446fn capture_cells(screen: &vt100::Screen, region: CellRegion) -> Vec<CellCapture> {
447    let mut cells = Vec::with_capacity(usize::from(region.rows) * usize::from(region.columns));
448    for row in region.row..region.row + region.rows {
449        for column in region.column..region.column + region.columns {
450            if let Some(cell) = screen.cell(row, column) {
451                cells.push(CellCapture {
452                    row,
453                    column,
454                    text: cell.contents().to_owned(),
455                    foreground: cell.fgcolor().into(),
456                    background: cell.bgcolor().into(),
457                    bold: cell.bold(),
458                    italic: cell.italic(),
459                    underline: cell.underline(),
460                    inverse: cell.inverse(),
461                    wide: cell.is_wide(),
462                    wide_continuation: cell.is_wide_continuation(),
463                });
464            }
465        }
466    }
467    cells
468}
469
470fn lock<T>(mutex: &Mutex<T>) -> Result<MutexGuard<'_, T>, TerminalError> {
471    mutex.lock().map_err(|_| TerminalError::Poisoned)
472}
473
474fn join_handle(handle: &Mutex<Option<JoinHandle<()>>>) -> Result<(), TerminalError> {
475    if let Some(handle) = lock(handle)?.take() {
476        handle.join().map_err(|_| TerminalError::Poisoned)?;
477    }
478    Ok(())
479}
480
481fn duration_ms(duration: Duration) -> u64 {
482    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
483}