use crate::os_input_output::{command_exists, AsyncReader}; use crate::panes::PaneId; use nix::{ fcntl::{fcntl, FcntlArg, OFlag}, pty::{openpty, OpenptyResult, Winsize}, sys::{ signal::{kill, Signal}, termios, }, unistd, }; use tokio::io::unix::AsyncFd; use libc::{self, ioctl, TIOCSWINSZ}; use signal_hook; use signal_hook::consts::*; use std::{ collections::BTreeMap, fs::File, io, os::fd::FromRawFd, os::unix::{ io::{AsRawFd, RawFd}, process::CommandExt, }, process::{Child, Command}, sync::{ atomic::{AtomicU32, Ordering}, Arc, Mutex, }, thread, time::Duration, }; use zellij_utils::{errors::prelude::*, input::command::RunCommand}; pub use async_trait::async_trait; /// An `AsyncReader` that wraps a `RawFd` using epoll via `AsyncFd`. /// /// Construction sets O_NONBLOCK but defers `AsyncFd` registration to the first /// `read()` call, because `AsyncFd::new()` requires a live Tokio reactor and /// `spawn_terminal` runs on the plain PTY thread (outside the runtime). struct RawFdAsyncReader { /// Holds the file before reactor registration; `None` after promotion. pending: Option, /// Populated on first `read()` inside the Tokio runtime. async_fd: Option>, } impl RawFdAsyncReader { fn new(fd: RawFd) -> io::Result { // Set O_NONBLOCK so AsyncFd can use epoll correctly let flags = fcntl(fd, FcntlArg::F_GETFL).map_err(|e| io::Error::from_raw_os_error(e as i32))?; let mut oflags = OFlag::from_bits_truncate(flags); oflags.insert(OFlag::O_NONBLOCK); fcntl(fd, FcntlArg::F_SETFL(oflags)).map_err(|e| io::Error::from_raw_os_error(e as i32))?; let file = unsafe { File::from_raw_fd(fd) }; Ok(Self { pending: Some(file), async_fd: None, }) } /// Lazily register with the Tokio reactor on first use. fn get_async_fd(&mut self) -> io::Result<&mut AsyncFd> { if self.async_fd.is_none() { let file = self .pending .take() .expect("RawFdAsyncReader used after init"); self.async_fd = Some(AsyncFd::new(file)?); } Ok(self.async_fd.as_mut().unwrap()) } } #[async_trait] impl AsyncReader for RawFdAsyncReader { async fn read(&mut self, buf: &mut [u8]) -> Result { let async_fd = self.get_async_fd()?; loop { let mut guard = async_fd.readable().await?; match guard.try_io(|inner| { let fd = inner.get_ref().as_raw_fd(); let ret = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; if ret < 0 { Err(io::Error::last_os_error()) } else { Ok(ret as usize) } }) { Ok(result) => return result, Err(_would_block) => continue, } } } } fn set_terminal_size_using_fd( fd: RawFd, columns: u16, rows: u16, width_in_pixels: Option, height_in_pixels: Option, ) { // TODO: do this with the nix ioctl let ws_xpixel = width_in_pixels.unwrap_or(0); let ws_ypixel = height_in_pixels.unwrap_or(0); let winsize = Winsize { ws_col: columns, ws_row: rows, ws_xpixel, ws_ypixel, }; // TIOCGWINSZ is an u32, but the second argument to ioctl is u64 on // some platforms. When checked on Linux, clippy will complain about // useless conversion. #[allow(clippy::useless_conversion)] unsafe { ioctl(fd, TIOCSWINSZ.into(), &winsize) }; } /// Handle some signals for the child process. This will loop until the child /// process exits. fn handle_command_exit(mut child: Child) -> Result> { let id = child.id(); let err_context = || { format!( "failed to handle signals and command exit for child process pid {}", id ) }; // returns the exit status, if any let mut should_exit = false; let mut attempts = 3; let mut signals = signal_hook::iterator::Signals::new(&[SIGINT, SIGTERM]).with_context(err_context)?; 'handle_exit: loop { // test whether the child process has exited match child.try_wait() { Ok(Some(status)) => { // if the child process has exited, break outside of the loop // and exit this function // TODO: handle errors? break 'handle_exit Ok(status.code()); }, Ok(None) => { thread::sleep(Duration::from_millis(10)); }, Err(e) => panic!("error attempting to wait: {}", e), } if !should_exit { for signal in signals.pending() { if signal == SIGINT || signal == SIGTERM { should_exit = true; } } } else if attempts > 0 { // let's try nicely first... attempts -= 1; kill( unistd::Pid::from_raw(child.id() as i32), Some(Signal::SIGTERM), ) .with_context(err_context)?; continue; } else { // when I say whoa, I mean WHOA! let _ = child.kill(); break 'handle_exit Ok(None); } } } fn handle_openpty( open_pty_res: OpenptyResult, cmd: RunCommand, quit_cb: Box, RunCommand) + Send>, terminal_id: u32, ) -> Result<(RawFd, RawFd)> { let err_context = |cmd: &RunCommand| { format!( "failed to open PTY for command '{}'", cmd.command.to_string_lossy().to_string() ) }; // primary side of pty and child fd let pid_primary = open_pty_res.master; let pid_secondary = open_pty_res.slave; if !command_exists(&cmd) { return Err(ZellijError::CommandNotFound { terminal_id, command: cmd.command.to_string_lossy().to_string(), }) .with_context(|| err_context(&cmd)); } let mut child = unsafe { let cmd = cmd.clone(); let command = &mut Command::new(cmd.command); if let Some(current_dir) = cmd.cwd { if current_dir.exists() && current_dir.is_dir() { command.current_dir(current_dir); } else { log::error!( "Failed to set CWD for new pane. '{}' does not exist or is not a folder", current_dir.display() ); } } command .args(&cmd.args) .env("ZELLIJ_PANE_ID", &format!("{}", terminal_id)) .pre_exec(move || -> io::Result<()> { if libc::login_tty(pid_secondary) != 0 { panic!("failed to set controlling terminal"); } close_fds::close_open_fds(3, &[]); Ok(()) }) .spawn() .expect("failed to spawn") }; let child_id = child.id(); thread::spawn(move || { child.wait().with_context(|| err_context(&cmd)).fatal(); let exit_status = handle_command_exit(child) .with_context(|| err_context(&cmd)) .fatal(); let _ = unistd::close(pid_secondary); quit_cb(PaneId::Terminal(terminal_id), exit_status, cmd); }); Ok((pid_primary, child_id as RawFd)) } /// Spawns a new terminal from the parent terminal with [`termios`](termios::Termios) /// `orig_termios`. fn handle_terminal( cmd: RunCommand, failover_cmd: Option, orig_termios: Option, quit_cb: Box, RunCommand) + Send>, terminal_id: u32, ) -> Result<(RawFd, RawFd)> { let err_context = || "failed to spawn child terminal".to_string(); // Create a pipe to allow the child the communicate the shell's pid to its // parent. match openpty(None, &orig_termios) { Ok(open_pty_res) => handle_openpty(open_pty_res, cmd, quit_cb, terminal_id), Err(e) => match failover_cmd { Some(failover_cmd) => { handle_terminal(failover_cmd, None, orig_termios, quit_cb, terminal_id) .with_context(err_context) }, None => Err::<(i32, i32), _>(e) .context("failed to start pty") .with_context(err_context) .to_log(), }, } } /// The Unix PTY backend. Manages native PTY file descriptors and signals. #[derive(Clone)] pub(crate) struct UnixPtyBackend { orig_termios: Arc>>, terminal_id_to_raw_fd: Arc>>>, next_terminal_id_counter: Arc, } /// Try to write as many bytes from `buf` as possible to `fd` without blocking. /// /// Loops on successful short writes and EINTR to drain as much as the kernel /// will accept. On EAGAIN (fd buffer full), stops and returns how many bytes /// were written so far (which may be 0). The caller is expected to re-queue /// any unwritten remainder. fn try_write_to_fd(fd: RawFd, buf: &[u8]) -> Result { let mut written = 0; while written < buf.len() { match unistd::write(fd, &buf[written..]) { Ok(0) => break, // fd returned 0 on non-empty buf; treat like EAGAIN Ok(n) => written += n, Err(nix::errno::Errno::EINTR) => continue, Err(nix::errno::Errno::EAGAIN) => break, Err(e) => return Err(e.into()), } } Ok(written) } impl UnixPtyBackend { pub fn new() -> Result { let current_termios = termios::tcgetattr(0).ok(); if current_termios.is_none() { log::warn!("Starting a server without a controlling terminal, using the default termios configuration."); } Ok(Self { orig_termios: Arc::new(Mutex::new(current_termios)), terminal_id_to_raw_fd: Arc::new(Mutex::new(BTreeMap::new())), next_terminal_id_counter: Arc::new(AtomicU32::new(0)), }) } pub fn spawn_terminal( &self, cmd: RunCommand, failover_cmd: Option, quit_cb: Box, RunCommand) + Send>, terminal_id: u32, ) -> Result<(Box, RawFd)> { let orig_termios = self .orig_termios .lock() .to_anyhow() .context("failed to lock orig_termios")?; let (pid_primary, child_fd) = handle_terminal( cmd, failover_cmd, orig_termios.clone(), quit_cb, terminal_id, )?; self.terminal_id_to_raw_fd .lock() .to_anyhow()? .insert(terminal_id, Some(pid_primary)); let async_reader = Box::new( RawFdAsyncReader::new(pid_primary) .map_err(|e| anyhow::anyhow!("failed to create async reader: {}", e))?, ) as Box; Ok((async_reader, child_fd)) } pub fn set_terminal_size( &self, terminal_id: u32, cols: u16, rows: u16, width_in_pixels: Option, height_in_pixels: Option, ) -> Result<()> { let err_context = || { format!( "failed to set terminal id {} to size ({}, {})", terminal_id, rows, cols ) }; match self .terminal_id_to_raw_fd .lock() .to_anyhow() .with_context(err_context)? .get(&terminal_id) { Some(Some(fd)) => { if cols > 0 && rows > 0 { set_terminal_size_using_fd(*fd, cols, rows, width_in_pixels, height_in_pixels); } }, _ => { Err::<(), _>(anyhow!("failed to find terminal fd for id {terminal_id}")) .with_context(err_context) .non_fatal(); }, } Ok(()) } pub fn write_to_tty_stdin(&self, terminal_id: u32, buf: &[u8]) -> Result { let err_context = || format!("failed to write to stdin of TTY ID {}", terminal_id); let fd = match self .terminal_id_to_raw_fd .lock() .to_anyhow() .with_context(err_context)? .get(&terminal_id) { Some(Some(fd)) => *fd, _ => { return Err(anyhow!("could not find raw file descriptor")).with_context(err_context) }, }; try_write_to_fd(fd, buf).with_context(err_context) } pub fn tcdrain(&self, terminal_id: u32) -> Result<()> { let err_context = || format!("failed to tcdrain to TTY ID {}", terminal_id); match self .terminal_id_to_raw_fd .lock() .to_anyhow() .with_context(err_context)? .get(&terminal_id) { Some(Some(fd)) => termios::tcdrain(*fd).with_context(err_context), _ => Err(anyhow!("could not find raw file descriptor")).with_context(err_context), } } pub fn tcgetpgrp(&self, terminal_id: u32) -> Option { match self.terminal_id_to_raw_fd.lock().ok()?.get(&terminal_id) { Some(Some(fd)) => unistd::tcgetpgrp(*fd).ok().map(|pgid| pgid.as_raw()), _ => None, } } pub fn kill(&self, pid: u32) -> Result<()> { let _ = kill(unistd::Pid::from_raw(pid as i32), Some(Signal::SIGHUP)); Ok(()) } pub fn force_kill(&self, pid: u32) -> Result<()> { let _ = kill(unistd::Pid::from_raw(pid as i32), Some(Signal::SIGKILL)); Ok(()) } pub fn send_sigint(&self, pid: u32) -> Result<()> { let _ = kill(unistd::Pid::from_raw(pid as i32), Some(Signal::SIGINT)); Ok(()) } pub fn reserve_terminal_id(&self, terminal_id: u32) { self.terminal_id_to_raw_fd .lock() .unwrap() .insert(terminal_id, None); } pub fn clear_terminal_id(&self, terminal_id: u32) { self.terminal_id_to_raw_fd .lock() .unwrap() .remove(&terminal_id); } pub fn next_terminal_id(&self) -> Option { Some( self.next_terminal_id_counter .fetch_add(1, Ordering::Relaxed), ) } } #[cfg(test)] mod tests { use super::*; use nix::fcntl::{fcntl, FcntlArg, OFlag}; use nix::sys::termios; use std::io::Read; /// Verify that `try_write_to_fd` writes as many bytes as the kernel will /// accept in one pass and returns a partial count (not an error) when the /// PTY buffer fills up. /// /// A concurrent reader drains the slave side so some bytes are accepted. /// The key assertion: the function returns Ok(n) where n <= buf.len(), /// and the caller (PtyWriter) is responsible for re-queuing the rest. #[test] fn try_write_to_fd_returns_partial_on_full_buffer() { let pty = openpty(None, &None).expect("openpty failed"); let mut attrs = termios::tcgetattr(pty.slave).expect("tcgetattr failed"); termios::cfmakeraw(&mut attrs); termios::tcsetattr(pty.slave, termios::SetArg::TCSANOW, &attrs).expect("tcsetattr failed"); // O_NONBLOCK so write() returns EAGAIN instead of blocking let flags = fcntl(pty.master, FcntlArg::F_GETFL).expect("F_GETFL"); let mut oflags = OFlag::from_bits_truncate(flags); oflags.insert(OFlag::O_NONBLOCK); fcntl(pty.master, FcntlArg::F_SETFL(oflags)).expect("F_SETFL"); // Fill most of the buffer, leaving some space let chunk = vec![0x42u8; 1024]; let mut total_filled = 0; loop { match super::try_write_to_fd(pty.master, &chunk) { Ok(0) => break, Ok(n) => total_filled += n, Err(e) => panic!("unexpected error filling buffer: {e}"), } } assert!( total_filled > 0, "should have written some bytes to fill buffer" ); // Read a small amount from the slave to free partial space let mut drain = vec![0u8; 512]; let slave_file = unsafe { std::fs::File::from_raw_fd(pty.slave) }; let mut slave_reader = std::io::BufReader::new(&slave_file); let drained = slave_reader.read(&mut drain).expect("slave read failed"); assert!(drained > 0, "should have drained some bytes"); // Prevent File from closing the slave fd — we close it manually below std::mem::forget(slave_file); // Now write more than the freed space — should get a partial write let size = 128 * 1024; let data: Vec = (0..size).map(|i| (i % 256) as u8).collect(); let written = super::try_write_to_fd(pty.master, &data) .expect("try_write_to_fd should not error on EAGAIN"); assert!( written > 0 && written < size, "expected partial write, got {written}/{size}", ); unsafe { libc::close(pty.master); libc::close(pty.slave); } } /// Verify that `try_write_to_fd` returns Ok(0) — not an error — when the /// fd is completely full and cannot accept any bytes at all. #[test] fn try_write_to_fd_returns_zero_on_stuck_pty() { let pty = openpty(None, &None).expect("openpty failed"); let mut attrs = termios::tcgetattr(pty.slave).expect("tcgetattr failed"); termios::cfmakeraw(&mut attrs); termios::tcsetattr(pty.slave, termios::SetArg::TCSANOW, &attrs).expect("tcsetattr failed"); let flags = fcntl(pty.master, FcntlArg::F_GETFL).expect("F_GETFL"); let mut oflags = OFlag::from_bits_truncate(flags); oflags.insert(OFlag::O_NONBLOCK); fcntl(pty.master, FcntlArg::F_SETFL(oflags)).expect("F_SETFL"); // Fill the buffer completely — keep writing until we get Ok(0) let fill = vec![0x42u8; 1024]; loop { match super::try_write_to_fd(pty.master, &fill) { Ok(0) => break, Ok(_) => continue, Err(e) => panic!("unexpected error filling buffer: {e}"), } } // Now the buffer is full — next write should return Ok(0) let written = super::try_write_to_fd(pty.master, &[0x01, 0x02, 0x03]) .expect("try_write_to_fd should not error on EAGAIN"); assert_eq!(written, 0, "expected zero bytes written on full buffer"); unsafe { libc::close(pty.master); libc::close(pty.slave); } } }