1use 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
27pub const SCROLLBACK_ROWS: usize = 2_000;
32
33pub struct Session {
35 id: u64,
36 label: String,
37 pid: Option<u32>,
38 parser: Arc<Mutex<vt100::Parser<Replies>>>,
39 output_signalled: Arc<AtomicBool>,
43 input: std_mpsc::Sender<Vec<u8>>,
44 killer: Box<dyn ChildKiller + Send + Sync>,
45 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 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 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 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 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 #[must_use]
162 pub fn id(&self) -> u64 {
163 self.id
164 }
165
166 #[must_use]
168 pub fn label(&self) -> &str {
169 &self.label
170 }
171
172 #[must_use]
174 pub fn pid(&self) -> Option<u32> {
175 self.pid
176 }
177
178 pub fn write(&self, bytes: Vec<u8>) {
180 let _ = self.input.send(bytes);
183 }
184
185 pub fn screen(&self) -> MutexGuard<'_, vt100::Parser<Replies>> {
190 self.parser.lock().unwrap_or_else(|poisoned| {
191 poisoned.into_inner()
194 })
195 }
196
197 pub fn output_drawn(&self) {
200 self.output_signalled.store(false, Ordering::Release);
201 }
202
203 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 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 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 tracing::debug!("terminal session {} kill: {e}", self.id);
244 }
245 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 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#[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 ('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 ('n', None, Some(5)) => self.pending.extend(b"\x1b[0n"),
345 ('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 #[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}