1#![allow(unsafe_code)]
2
3use std::io::{Read, Write};
4use std::sync::{
5 Arc, Condvar, Mutex,
6 atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering},
7};
8use std::thread::JoinHandle;
9use std::time::Duration;
10
11#[cfg(unix)]
12use std::fs::File;
13#[cfg(unix)]
14use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
15
16use portable_pty::{CommandBuilder, PtySize, native_pty_system};
17
18use super::events::{TerminalKeyModes, key_event_to_bytes};
19use super::screen::TerminalCellSize;
20use crate::core::event::KeyEvent;
21
22#[derive(thiserror::Error, Debug)]
24pub enum TerminalPtyError {
25 #[error("pty initialization failed: {0}")]
27 Setup(String),
28 #[error("failed to clone pty reader: {0}")]
30 Reader(String),
31 #[error("failed to acquire pty writer: {0}")]
33 Writer(String),
34 #[error("failed to spawn pty command: {0}")]
36 Spawn(String),
37}
38
39fn default_shell_command() -> String {
49 #[cfg(windows)]
50 {
51 std::env::var("COMSPEC")
52 .ok()
53 .filter(|value| !value.trim().is_empty())
54 .unwrap_or_else(|| "cmd.exe".to_string())
55 }
56 #[cfg(not(windows))]
57 {
58 std::env::var("SHELL")
59 .ok()
60 .filter(|value| !value.trim().is_empty())
61 .unwrap_or_else(|| "/bin/sh".to_string())
62 }
63}
64
65#[cfg(windows)]
66fn prime_conpty_cursor(writer: &mut dyn Write) -> std::io::Result<()> {
67 writer.write_all(b"\x1b[1;1R")?;
70 writer.flush()
71}
72
73#[cfg(not(windows))]
74fn prime_conpty_cursor(_writer: &mut dyn Write) -> std::io::Result<()> {
75 Ok(())
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct TerminalPtyConfig {
81 pub(crate) command: Arc<str>,
82 pub(crate) args: Vec<Arc<str>>,
83 pub(crate) cols: u16,
84 pub(crate) rows: u16,
85 pub(crate) cwd: Option<Arc<str>>,
86 pub(crate) term: Arc<str>,
87 pub(crate) env: Vec<(Arc<str>, Arc<str>)>,
88 pub(crate) cell: TerminalCellSize,
89}
90
91impl Default for TerminalPtyConfig {
92 fn default() -> Self {
93 let shell = default_shell_command();
94
95 Self {
96 command: shell.into(),
97 args: Vec::new(),
98 cols: 120,
99 rows: 32,
100 cwd: None,
101 term: Arc::from("xterm-256color"),
102 env: vec![(Arc::from("COLORTERM"), Arc::from("truecolor"))],
103 cell: TerminalCellSize::default(),
104 }
105 }
106}
107
108impl TerminalPtyConfig {
109 pub fn cell_size(mut self, cell: TerminalCellSize) -> Self {
115 self.cell = cell;
116 self
117 }
118
119 pub fn new(command: impl Into<Arc<str>>) -> Self {
121 Self {
122 command: command.into(),
123 ..Self::default()
124 }
125 }
126
127 pub fn arg(mut self, arg: impl Into<Arc<str>>) -> Self {
129 self.args.push(arg.into());
130 self
131 }
132
133 pub fn args<I>(mut self, args: I) -> Self
135 where
136 I: IntoIterator<Item = Arc<str>>,
137 {
138 self.args = args.into_iter().collect();
139 self
140 }
141
142 pub fn size(mut self, cols: u16, rows: u16) -> Self {
144 self.cols = cols.max(1);
145 self.rows = rows.max(1);
146 self
147 }
148
149 pub fn cwd(mut self, cwd: impl Into<Arc<str>>) -> Self {
151 self.cwd = Some(cwd.into());
152 self
153 }
154
155 pub fn term(mut self, term: impl Into<Arc<str>>) -> Self {
157 self.term = term.into();
158 self
159 }
160
161 pub fn env(mut self, key: impl Into<Arc<str>>, value: impl Into<Arc<str>>) -> Self {
163 self.env.push((key.into(), value.into()));
164 self
165 }
166}
167
168pub struct TerminalPty {
173 inner: Arc<TerminalPtyInner>,
174}
175
176impl Clone for TerminalPty {
177 fn clone(&self) -> Self {
178 self.inner.handle_count.fetch_add(1, Ordering::AcqRel);
185 Self {
186 inner: self.inner.clone(),
187 }
188 }
189}
190
191const EXIT_DRAIN_GRACE: Duration = Duration::from_secs(2);
199
200#[derive(Default)]
209struct ExitSync {
210 state: Mutex<ExitState>,
211 signal: Condvar,
212}
213
214#[derive(Default)]
215struct ExitState {
216 code: Option<i32>,
218 emitted: bool,
220 stopped: bool,
223}
224
225impl ExitSync {
226 fn publish(&self, code: i32) {
228 if let Ok(mut state) = self.state.lock() {
229 state.code = Some(code);
230 }
231 self.signal.notify_all();
232 }
233
234 fn stop(&self) {
236 if let Ok(mut state) = self.state.lock() {
237 state.stopped = true;
238 }
239 self.signal.notify_all();
240 }
241
242 fn claim(&self) -> Option<i32> {
247 let mut state = self.state.lock().ok()?;
248 let code = state.code?;
249 if state.emitted {
250 return None;
251 }
252 state.emitted = true;
253 Some(code)
254 }
255
256 fn claim_when_known(&self, timeout: Duration) -> Option<i32> {
258 self.claim_when(timeout, |state| state.code.is_none())
259 }
260
261 fn claim_after_drain(&self, timeout: Duration) -> Option<i32> {
263 self.claim_when(timeout, |state| !state.emitted)
264 }
265
266 fn claim_when(
267 &self,
268 timeout: Duration,
269 mut keep_waiting: impl FnMut(&mut ExitState) -> bool,
270 ) -> Option<i32> {
271 let state = self.state.lock().ok()?;
272 let (state, _) = self
273 .signal
274 .wait_timeout_while(state, timeout, |state| {
275 !state.stopped && keep_waiting(state)
276 })
277 .ok()?;
278 drop(state);
279 self.claim()
280 }
281}
282
283struct TerminalPtyInner {
284 backend: Mutex<TerminalPtyBackend>,
285 writer: Mutex<Option<Box<dyn Write + Send>>>,
286 killer: Mutex<Option<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
287 reader_thread: Mutex<Option<JoinHandle<()>>>,
288 exit: ExitSync,
290 active: AtomicBool,
291 kill_on_drop: AtomicBool,
292 handle_count: AtomicUsize,
294 pid: Option<u32>,
296 cell: AtomicCellSize,
298}
299
300struct AtomicCellSize(AtomicU32);
304
305impl AtomicCellSize {
306 fn new(cell: TerminalCellSize) -> Self {
307 let slot = Self(AtomicU32::new(0));
308 slot.store(cell);
309 slot
310 }
311
312 fn load(&self) -> TerminalCellSize {
313 let packed = self.0.load(Ordering::Acquire);
314 TerminalCellSize::new((packed >> 16) as u16, packed as u16)
315 }
316
317 fn store(&self, cell: TerminalCellSize) {
318 let packed = (u32::from(cell.width) << 16) | u32::from(cell.height);
319 self.0.store(packed, Ordering::Release);
320 }
321}
322
323fn pty_size(cols: u16, rows: u16, cell: TerminalCellSize) -> PtySize {
325 let cols = cols.max(1);
326 let rows = rows.max(1);
327 PtySize {
328 rows,
329 cols,
330 pixel_width: cols.saturating_mul(cell.width),
331 pixel_height: rows.saturating_mul(cell.height),
332 }
333}
334
335enum TerminalPtyBackend {
336 Portable(Box<dyn portable_pty::MasterPty + Send>),
337}
338
339#[cfg(unix)]
340pub struct TerminalPtyHandoff {
342 pub master_fd: RawFd,
344 pub pid: Option<u32>,
346 _keepalive: TerminalPty,
347}
348
349impl TerminalPty {
350 pub fn spawn(
352 config: TerminalPtyConfig,
353 on_event: impl Fn(TerminalPtyEvent) + Send + Sync + 'static,
354 ) -> Result<Self, TerminalPtyError> {
355 let pty_system = native_pty_system();
356 let pair = pty_system
357 .openpty(pty_size(config.cols, config.rows, config.cell))
358 .map_err(|err| TerminalPtyError::Setup(err.to_string()))?;
359
360 #[cfg(unix)]
361 let reader = File::from(
362 unix_dup_master_fd(&*pair.master)
363 .map_err(|err| TerminalPtyError::Reader(err.to_string()))?,
364 );
365 #[cfg(not(unix))]
366 let reader = pair
367 .master
368 .try_clone_reader()
369 .map_err(|err| TerminalPtyError::Reader(err.to_string()))?;
370
371 #[cfg(unix)]
372 let mut writer = Box::new(File::from(
373 unix_dup_master_fd(&*pair.master)
374 .map_err(|err| TerminalPtyError::Writer(err.to_string()))?,
375 )) as Box<dyn Write + Send>;
376 #[cfg(not(unix))]
377 let mut writer = pair
378 .master
379 .take_writer()
380 .map_err(|err| TerminalPtyError::Writer(err.to_string()))?;
381
382 prime_conpty_cursor(&mut *writer)
383 .map_err(|err| TerminalPtyError::Writer(err.to_string()))?;
384
385 let mut builder = CommandBuilder::new(config.command.as_ref());
386 for arg in config.args {
387 builder.arg(arg.as_ref());
388 }
389 builder.env("TERM", config.term.as_ref());
390 if let Some(cwd) = config.cwd {
391 builder.cwd(cwd.as_ref());
392 }
393 for (key, value) in config.env {
394 builder.env(key.as_ref(), value.as_ref());
395 }
396
397 let mut child = pair
398 .slave
399 .spawn_command(builder)
400 .map_err(|err| TerminalPtyError::Spawn(err.to_string()))?;
401
402 let pid = child.process_id();
403 let inner = Arc::new(TerminalPtyInner {
404 backend: Mutex::new(TerminalPtyBackend::Portable(pair.master)),
405 writer: Mutex::new(Some(writer)),
406 killer: Mutex::new(Some(child.clone_killer())),
407 reader_thread: Mutex::new(None),
408 exit: ExitSync::default(),
409 active: AtomicBool::new(true),
410 kill_on_drop: AtomicBool::new(true),
411 handle_count: AtomicUsize::new(1),
412 pid,
413 cell: AtomicCellSize::new(config.cell),
414 });
415
416 let on_event = Arc::new(on_event);
417
418 {
419 let on_event = on_event.clone();
420 let inner = inner.clone();
421 let thread_inner = inner.clone();
422 let reader_thread = std::thread::spawn(move || {
423 let mut reader = reader;
424 let mut buffer = [0u8; 8192];
425 let mut stream_ended = false;
428 loop {
429 if !thread_inner.active.load(Ordering::Acquire) {
430 break;
431 }
432 #[cfg(unix)]
433 match unix_wait_readable(reader.as_raw_fd(), &thread_inner.active) {
434 PtyReadiness::Readable => {}
435 PtyReadiness::Idle => {
436 if let Some(code) = thread_inner.exit.claim() {
440 on_event(TerminalPtyEvent::Exited(code));
441 }
442 continue;
443 }
444 PtyReadiness::Stop => break,
445 }
446 match reader.read(&mut buffer) {
447 Ok(0) => {
448 stream_ended = true;
449 break;
450 }
451 Ok(read) => {
452 if !thread_inner.active.load(Ordering::Acquire) {
453 break;
454 }
455 on_event(TerminalPtyEvent::Output(Arc::<[u8]>::from(
456 buffer[..read].to_vec(),
457 )));
458 }
459 Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
460 Err(err) => {
461 stream_ended = true;
462 #[cfg(unix)]
467 if err.raw_os_error() == Some(libc::EIO) {
468 break;
469 }
470 on_event(TerminalPtyEvent::Error(err.to_string().into()));
471 break;
472 }
473 }
474 }
475 if stream_ended
480 && let Some(code) = thread_inner.exit.claim_when_known(EXIT_DRAIN_GRACE)
481 {
482 on_event(TerminalPtyEvent::Exited(code));
483 }
484 });
485 if let Ok(mut slot) = inner.reader_thread.lock() {
486 *slot = Some(reader_thread);
487 }
488 }
489
490 {
491 let on_event = on_event.clone();
492 let thread_inner = inner.clone();
493 std::thread::spawn(move || {
494 let exit_code = child
495 .wait()
496 .ok()
497 .map(|status| status.exit_code() as i32)
498 .unwrap_or(-1);
499 thread_inner.exit.publish(exit_code);
504 if let Some(code) = thread_inner.exit.claim_after_drain(EXIT_DRAIN_GRACE) {
505 on_event(TerminalPtyEvent::Exited(code));
506 }
507 });
508 }
509
510 Ok(Self { inner })
511 }
512
513 #[cfg(unix)]
514 pub fn handoff(&self) -> std::io::Result<TerminalPtyHandoff> {
516 self.inner.active.store(false, Ordering::Release);
517 self.inner.exit.stop();
520 self.inner.kill_on_drop.store(false, Ordering::Release);
521 if let Some(handle) = self
522 .inner
523 .reader_thread
524 .lock()
525 .map_err(|_| std::io::Error::other("pty reader thread lock poisoned"))?
526 .take()
527 {
528 let _ = handle.join();
529 }
530 let mut writer = self
531 .inner
532 .writer
533 .lock()
534 .map_err(|_| std::io::Error::other("pty writer lock poisoned"))?;
535 writer.take();
536 drop(writer);
537
538 let backend = self
539 .inner
540 .backend
541 .lock()
542 .map_err(|_| std::io::Error::other("pty master lock poisoned"))?;
543 let fd = match &*backend {
544 TerminalPtyBackend::Portable(master) => master
545 .as_raw_fd()
546 .ok_or_else(|| std::io::Error::other("pty master fd unavailable"))?,
547 };
548 Ok(TerminalPtyHandoff {
549 master_fd: fd,
550 pid: self.inner.pid,
551 _keepalive: self.clone(),
552 })
553 }
554
555 pub fn pid(&self) -> Option<u32> {
557 self.inner.pid
558 }
559
560 #[cfg(unix)]
561 pub fn foreground_process_group_id(&self) -> Option<i32> {
568 if !self.inner.active.load(Ordering::Acquire) {
569 return None;
570 }
571 let backend = self.inner.backend.lock().ok()?;
572 let fd = match &*backend {
573 TerminalPtyBackend::Portable(master) => master.as_raw_fd()?,
574 };
575 let pgid = unsafe { libc::tcgetpgrp(fd) };
576 (pgid >= 0).then_some(pgid)
577 }
578
579 pub fn write(&self, bytes: &[u8]) -> std::io::Result<()> {
581 if !self.inner.active.load(Ordering::Acquire) {
582 return Err(std::io::Error::other("pty has been handed off"));
583 }
584 let mut writer = self
585 .inner
586 .writer
587 .lock()
588 .map_err(|_| std::io::Error::other("pty writer lock poisoned"))?;
589 let writer = writer
590 .as_mut()
591 .ok_or_else(|| std::io::Error::other("pty writer unavailable"))?;
592 writer.write_all(bytes)?;
593 writer.flush()
594 }
595
596 pub fn send_key(&self, key: KeyEvent, modes: TerminalKeyModes) -> std::io::Result<bool> {
601 let Some(bytes) = key_event_to_bytes(key, modes) else {
602 return Ok(false);
603 };
604 self.write(&bytes)?;
605 Ok(true)
606 }
607
608 pub fn resize(&self, cols: u16, rows: u16) -> std::io::Result<()> {
613 self.resize_with_cell_size(cols, rows, self.inner.cell.load())
614 }
615
616 pub fn resize_with_cell_size(
623 &self,
624 cols: u16,
625 rows: u16,
626 cell: TerminalCellSize,
627 ) -> std::io::Result<()> {
628 if !self.inner.active.load(Ordering::Acquire) {
629 return Err(std::io::Error::other("pty has been handed off"));
630 }
631 let backend = self
632 .inner
633 .backend
634 .lock()
635 .map_err(|_| std::io::Error::other("pty master lock poisoned"))?;
636 match &*backend {
637 TerminalPtyBackend::Portable(master) => {
638 self.inner.cell.store(cell);
639 master
640 .resize(pty_size(cols, rows, cell))
641 .map_err(|err| std::io::Error::other(err.to_string()))
642 }
643 }
644 }
645
646 pub fn kill(&self) -> std::io::Result<()> {
648 if !self.inner.kill_on_drop.load(Ordering::Acquire) {
649 return Ok(());
650 }
651 self.inner.active.store(false, Ordering::Release);
652 self.inner.exit.stop();
655 let mut killer = self
656 .inner
657 .killer
658 .lock()
659 .map_err(|_| std::io::Error::other("pty killer lock poisoned"))?;
660 if let Some(killer) = killer.as_mut() {
661 return killer
662 .kill()
663 .map_err(|err| std::io::Error::other(err.to_string()));
664 }
665 Ok(())
666 }
667}
668
669#[cfg(unix)]
670fn unix_dup_master_fd(master: &dyn portable_pty::MasterPty) -> std::io::Result<OwnedFd> {
671 let fd = master
672 .as_raw_fd()
673 .ok_or_else(|| std::io::Error::other("pty master fd unavailable"))?;
674 unix_dup_raw_fd(fd)
675}
676
677#[cfg(unix)]
678fn unix_dup_raw_fd(fd: RawFd) -> std::io::Result<OwnedFd> {
679 let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
680 if dup < 0 {
681 return Err(std::io::Error::last_os_error());
682 }
683 Ok(unsafe { OwnedFd::from_raw_fd(dup) })
684}
685
686#[cfg(unix)]
688enum PtyReadiness {
689 Readable,
691 Idle,
693 Stop,
695}
696
697#[cfg(unix)]
698fn unix_wait_readable(fd: RawFd, active: &AtomicBool) -> PtyReadiness {
699 if !active.load(Ordering::Acquire) {
700 return PtyReadiness::Stop;
701 }
702 let mut pollfd = libc::pollfd {
703 fd,
704 events: libc::POLLIN,
705 revents: 0,
706 };
707 let rc = unsafe { libc::poll(&mut pollfd, 1, 100) };
708 if rc > 0 {
709 if pollfd.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 {
710 return PtyReadiness::Readable;
711 }
712 if pollfd.revents & libc::POLLNVAL != 0 {
715 return PtyReadiness::Stop;
716 }
717 return PtyReadiness::Idle;
718 }
719 if rc == 0 {
720 return PtyReadiness::Idle;
721 }
722 if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
723 return PtyReadiness::Idle;
724 }
725 PtyReadiness::Stop
726}
727
728#[derive(Clone, Debug, PartialEq, Eq)]
735pub enum TerminalPtyEvent {
736 Output(Arc<[u8]>),
738 Exited(i32),
740 Error(Arc<str>),
742}
743
744impl Drop for TerminalPty {
745 fn drop(&mut self) {
746 if self.inner.handle_count.fetch_sub(1, Ordering::AcqRel) == 1 {
749 let _ = self.kill();
751 }
752 }
753}
754
755#[cfg(all(test, unix))]
756mod tests {
757 use super::*;
758
759 #[test]
760 fn dropping_a_clone_does_not_kill_the_shared_pty() {
761 let pty = TerminalPty::spawn(
762 TerminalPtyConfig::new("/bin/sh").arg("-c").arg("sleep 5"),
763 |_event| {},
764 )
765 .expect("spawn");
766
767 let clone = pty.clone();
768 assert_eq!(pty.inner.handle_count.load(Ordering::Acquire), 2);
769 drop(clone);
770 assert_eq!(pty.inner.handle_count.load(Ordering::Acquire), 1);
771
772 assert!(
775 pty.write(b"").is_ok(),
776 "pty should still be alive after dropping a clone"
777 );
778
779 drop(pty);
780 }
781
782 #[test]
788 fn a_fast_command_s_output_arrives_before_its_exit() {
789 for attempt in 0..40 {
792 let events = Arc::new(Mutex::new(Vec::new()));
793 let sink = events.clone();
794 let mut pty = Some(
795 TerminalPty::spawn(
796 TerminalPtyConfig::new("/bin/sh")
797 .arg("-c")
798 .arg("printf 'fast output\\n'; exit 3"),
799 move |event| sink.lock().expect("events").push(event),
800 )
801 .expect("spawn"),
802 );
803
804 let deadline = std::time::Instant::now() + Duration::from_secs(10);
805 while std::time::Instant::now() < deadline {
806 let exited = events
807 .lock()
808 .expect("events")
809 .iter()
810 .any(|event| matches!(event, TerminalPtyEvent::Exited(_)));
811 if exited {
812 break;
813 }
814 std::thread::sleep(Duration::from_millis(5));
815 }
816 drop(pty.take());
818
819 let events = events.lock().expect("events");
820 let text: String = events
821 .iter()
822 .filter_map(|event| match event {
823 TerminalPtyEvent::Output(bytes) => Some(String::from_utf8_lossy(bytes)),
824 _ => None,
825 })
826 .collect();
827 assert!(
828 text.contains("fast output"),
829 "attempt {attempt}: the command's output was lost; events: {events:?}"
830 );
831 assert!(
832 matches!(events.last(), Some(TerminalPtyEvent::Exited(3))),
833 "attempt {attempt}: the exit must come last and carry the real status; \
834 events: {events:?}"
835 );
836 }
837 }
838
839 #[test]
842 fn killing_a_pty_reports_the_exit_without_waiting_for_a_drain() {
843 let events = Arc::new(Mutex::new(Vec::new()));
844 let sink = events.clone();
845 let pty = TerminalPty::spawn(
846 TerminalPtyConfig::new("/bin/sh").arg("-c").arg("sleep 30"),
847 move |event| sink.lock().expect("events").push(event),
848 )
849 .expect("spawn");
850
851 let started = std::time::Instant::now();
852 pty.kill().expect("kill");
853 let deadline = started + EXIT_DRAIN_GRACE;
854 while std::time::Instant::now() < deadline {
855 if events
856 .lock()
857 .expect("events")
858 .iter()
859 .any(|event| matches!(event, TerminalPtyEvent::Exited(_)))
860 {
861 return;
862 }
863 std::thread::sleep(Duration::from_millis(5));
864 }
865 panic!("a killed pty must report its exit without waiting out the drain grace");
866 }
867
868 #[test]
869 fn foreground_process_group_id_reports_a_value_while_alive() {
870 let pty = TerminalPty::spawn(
871 TerminalPtyConfig::new("/bin/sh").arg("-c").arg("sleep 5"),
872 |_event| {},
873 )
874 .expect("spawn");
875
876 assert!(pty.foreground_process_group_id().is_some());
878
879 drop(pty);
880 }
881}