Vectrum
Sign In
234 lines · 8.3 KB · Rust
//! 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");
    }
}