//! 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<u8>),
/// 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<u8>),
Resize(u16, u16),
}
/// A running shell. Dropping it hangs up on the shell.
pub struct HostShell {
input: mpsc::Sender<Input>,
stop: Arc<AtomicBool>,
}
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> {
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<Self> {
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::<Input>();
{
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<u8>) {
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<bool> {
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<Self> {
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "terminals for other devices need Linux or macOS"))
}
pub fn write(&self, _: Vec<u8>) {}
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");
}
}