//! The host's side of a client's terminal: a shell in a pseudo-terminal on //! this device, whose output goes straight to the client from its own //! thread, without waiting on the UI. The client emulates the screen. use std::path::Path; /// What a shell does, for the connection to pass on. pub enum ShellEvent { Output(Vec), /// The shell exited. Exited, } #[cfg(unix)] pub use unix::HostShell; #[cfg(unix)] mod unix { use std::fs::File; use std::io::{self, ErrorKind, Read as _, Write as _}; use std::os::fd::AsRawFd as _; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, mpsc, }; use std::thread; use alacritty_terminal::event::{OnResize as _, WindowSize}; use alacritty_terminal::tty; use super::{Path, ShellEvent}; /// How long the reader waits for output before checking whether to stop. const WAIT_MS: i32 = 100; /// The most one output message carries. const CHUNK: usize = 64 * 1024; enum Input { Bytes(Vec), Resize(u16, u16), } /// A running shell. Dropping it hangs up on the shell. pub struct HostShell { input: mpsc::Sender, stop: Arc, } impl HostShell { /// Starts the user's shell in `cwd` with a screen of `columns` by /// `lines`. Its output and exit go to `on_event`, on another thread. pub fn spawn( cwd: &Path, columns: u16, lines: u16, on_event: impl Fn(ShellEvent) + Send + 'static, ) -> io::Result { Self::spawn_with(crate::terminal::shell_options(cwd), columns, lines, on_event) } pub(in crate::remote::shell) fn spawn_with( options: tty::Options, columns: u16, lines: u16, on_event: impl Fn(ShellEvent) + Send + 'static, ) -> io::Result { let mut pty = tty::new(&options, window_size(columns, lines), 0)?; let reader = pty.file().try_clone()?; let writer = pty.file().try_clone()?; let stop = Arc::new(AtomicBool::new(false)); let (input, inbox) = mpsc::channel::(); { let stop = stop.clone(); thread::Builder::new().name("remote-shell-out".into()).spawn(move || { read_output(&reader, &stop, &on_event); if !stop.load(Ordering::Relaxed) { on_event(ShellEvent::Exited); } })?; } thread::Builder::new().name("remote-shell-in".into()).spawn(move || { // Owns the PTY: it ends, and the shell with it, once the // shell is dropped. while let Ok(input) = inbox.recv() { match input { Input::Bytes(bytes) => { if write_all(&writer, &bytes).is_err() { break; } } Input::Resize(columns, lines) => pty.on_resize(window_size(columns, lines)), } } drop(pty); })?; Ok(Self { input, stop }) } /// Sends bytes to the shell, as if typed. pub fn write(&self, bytes: Vec) { if !bytes.is_empty() { let _ = self.input.send(Input::Bytes(bytes)); } } pub fn resize(&self, columns: u16, lines: u16) { let _ = self.input.send(Input::Resize(columns, lines)); } } impl Drop for HostShell { fn drop(&mut self) { // The reader stops without reporting an exit; the writer's inbox // closes with this, and it hangs up. self.stop.store(true, Ordering::Relaxed); } } fn window_size(columns: u16, lines: u16) -> WindowSize { WindowSize { num_lines: lines.max(1), num_cols: columns.max(2), cell_width: 8, cell_height: 17 } } /// Waits until the PTY is ready for `events`, or the wait runs out. fn ready(file: &File, events: i16, timeout_ms: i32) -> io::Result { let mut poll = libc::pollfd { fd: file.as_raw_fd(), events, revents: 0 }; // SAFETY: one valid pollfd for the duration of the call. let result = unsafe { libc::poll(&mut poll, 1, timeout_ms) }; if result < 0 { let err = io::Error::last_os_error(); return if err.kind() == ErrorKind::Interrupted { Ok(false) } else { Err(err) }; } Ok(result > 0) } /// Passes the shell's output on until it exits (the PTY then reads as /// closed) or the shell is dropped. fn read_output(mut file: &File, stop: &AtomicBool, on_event: &impl Fn(ShellEvent)) { let mut buf = vec![0u8; CHUNK]; while !stop.load(Ordering::Relaxed) { match ready(file, libc::POLLIN, WAIT_MS) { Ok(true) => {} Ok(false) => continue, Err(_) => return, } let mut filled = 0; // Take what's there now in one message, not one per read. loop { match file.read(&mut buf[filled..]) { Ok(0) => break, Ok(n) => filled += n, Err(err) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) => break, // EIO: the shell is gone. Err(_) if filled == 0 => return, Err(_) => break, } if filled == buf.len() || !matches!(ready(file, libc::POLLIN, 0), Ok(true)) { break; } } if filled == 0 { // Readable but empty: closed. return; } on_event(ShellEvent::Output(buf[..filled].to_vec())); } } /// Writes everything to the non-blocking PTY, waiting while it's full. fn write_all(mut file: &File, mut bytes: &[u8]) -> io::Result<()> { while !bytes.is_empty() { match file.write(bytes) { Ok(0) => return Err(ErrorKind::WriteZero.into()), Ok(n) => bytes = &bytes[n..], Err(err) if err.kind() == ErrorKind::WouldBlock => { ready(file, libc::POLLOUT, WAIT_MS)?; } Err(err) if err.kind() == ErrorKind::Interrupted => {} Err(err) => return Err(err), } } Ok(()) } } /// Elsewhere, clients' terminals don't start. #[cfg(not(unix))] pub struct HostShell; #[cfg(not(unix))] impl HostShell { pub fn spawn(_: &Path, _: u16, _: u16, _: impl Fn(ShellEvent) + Send + 'static) -> std::io::Result { Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "terminals for other devices need Linux or macOS")) } pub fn write(&self, _: Vec) {} pub fn resize(&self, _: u16, _: u16) {} } #[cfg(all(test, unix))] mod tests { use std::time::{Duration, Instant}; use super::{HostShell, ShellEvent}; #[test] fn runs_a_shell_and_reports_its_exit() { let dir = std::env::temp_dir(); let (events, received) = std::sync::mpsc::channel(); // A plain shell, not the user's with its startup files. let mut options = crate::terminal::shell_options(&dir); options.shell = Some(alacritty_terminal::tty::Shell::new("/bin/sh".into(), Vec::new())); let shell = HostShell::spawn_with(options, 80, 24, move |event| { let _ = events.send(event); }) .unwrap(); // Quoted apart, so the echoed command line doesn't match. shell.write(b"echo vectrum\"\"-ok; exit\n".to_vec()); let deadline = Instant::now() + Duration::from_secs(10); let mut output = Vec::new(); let mut exited = false; while Instant::now() < deadline && !exited { match received.recv_timeout(Duration::from_millis(200)) { Ok(ShellEvent::Output(bytes)) => output.extend(bytes), Ok(ShellEvent::Exited) => exited = true, Err(_) => {} } } let output = String::from_utf8_lossy(&output); assert!(output.contains("vectrum-ok"), "output: {output:?}"); assert!(exited, "the shell's exit wasn't reported"); } }