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
54pub 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 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 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 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 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 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 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 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 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}