use super::options::EnterOptions;
use super::verbs::{self, CursorStyle};
use super::waker::TerminalWaker;
use super::{TermRead, Terminal};
use crate::base::{Error, PixelSize, Result, Size};
use std::mem;
use std::os::unix::io::RawFd;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[path = "unix_sys.rs"]
mod sys;
pub use sys::emergency_restore;
use sys::{
claim_winch, io_err, last_errno, release_winch, set_cloexec_nonblock, write_all_fd,
EmergencySlot, WinchClaim, EMERGENCY,
};
pub(crate) use sys::IoctlReq;
const READ_BUF_LEN: usize = 4096;
const NO_SIGNAL_SLICE: Duration = Duration::from_millis(250);
struct EnteredState {
saved_termios: libc::termios,
opts: EnterOptions,
winch: Option<WinchClaim>,
}
struct WakeFd(RawFd);
impl Drop for WakeFd {
fn drop(&mut self) {
unsafe { libc::close(self.0) };
}
}
fn open_named_tty(tty_fd: RawFd) -> Option<RawFd> {
let mut buf = [0 as libc::c_char; 1024];
if unsafe { libc::ttyname_r(tty_fd, buf.as_mut_ptr(), buf.len()) } != 0 {
return None;
}
let name = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) };
if name.to_bytes() == b"/dev/tty" {
return None; }
let real = unsafe { libc::open(buf.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
if real < 0 {
return None;
}
if unsafe { libc::isatty(real) } != 1 {
unsafe { libc::close(real) };
return None;
}
Some(real)
}
pub fn have_tty() -> bool {
let fd = unsafe { libc::open(c"/dev/tty".as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
if fd >= 0 {
unsafe { libc::close(fd) };
return true;
}
unsafe { libc::isatty(libc::STDIN_FILENO) == 1 && libc::isatty(libc::STDOUT_FILENO) == 1 }
}
pub struct UnixTerminal {
read_fd: RawFd,
write_fd: RawFd,
owns_fds: bool,
entered: Option<EnteredState>,
out: Vec<u8>,
in_buf: Vec<u8>,
seen_size: Size,
wake_rd: Option<RawFd>,
waker: Option<TerminalWaker>,
cursor_styled: bool,
title_pushed: bool,
pixel_moused: bool,
degraded: Option<&'static str>,
}
impl UnixTerminal {
pub fn new() -> Result<Self> {
let (in_tty, out_tty, err_tty) = unsafe {
(
libc::isatty(libc::STDIN_FILENO) == 1,
libc::isatty(libc::STDOUT_FILENO) == 1,
libc::isatty(libc::STDERR_FILENO) == 1,
)
};
if in_tty && out_tty {
return Ok(Self::from_fds(
libc::STDIN_FILENO,
libc::STDOUT_FILENO,
false,
));
}
for (fd, is_tty) in [
(libc::STDIN_FILENO, in_tty),
(libc::STDOUT_FILENO, out_tty),
(libc::STDERR_FILENO, err_tty),
] {
if !is_tty {
continue;
}
if let Some(real) = open_named_tty(fd) {
return Ok(Self::from_fds(real, real, true));
}
}
let alias = unsafe { libc::open(c"/dev/tty".as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
if alias >= 0 {
return Ok(Self::from_fds(alias, alias, true));
}
Err(Error::Term(
"no terminal attached: stdin/stdout/stderr are all redirected and \
/dev/tty is unavailable — run inside a terminal emulator, or use \
testing::CaptureTerm for headless/CI runs"
.into(),
))
}
pub fn from_fds(read_fd: RawFd, write_fd: RawFd, owns_fds: bool) -> Self {
let (wake_rd, waker) = Self::make_wake_pipe();
UnixTerminal {
read_fd,
write_fd,
owns_fds,
entered: None,
out: Vec::with_capacity(8192),
in_buf: vec![0; READ_BUF_LEN],
seen_size: Size::ZERO,
wake_rd,
waker,
cursor_styled: false,
title_pushed: false,
pixel_moused: false,
degraded: None,
}
}
pub fn degraded(&self) -> Option<&'static str> {
self.degraded
}
#[cfg(not(test))]
fn deliver_stop() {
unsafe { libc::kill(0, libc::SIGTSTP) };
}
#[cfg(test)]
fn deliver_stop() {}
fn make_wake_pipe() -> (Option<RawFd>, Option<TerminalWaker>) {
let mut fds = [0 as libc::c_int; 2];
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
return (None, None);
}
if set_cloexec_nonblock(fds[0]).is_err() || set_cloexec_nonblock(fds[1]).is_err() {
unsafe {
libc::close(fds[0]);
libc::close(fds[1]);
}
return (None, None);
}
let wr = Arc::new(WakeFd(fds[1]));
let waker = TerminalWaker::new(move || {
let byte = 1u8;
unsafe { libc::write(wr.0, &byte as *const u8 as *const libc::c_void, 1) };
});
(Some(fds[0]), Some(waker))
}
fn raw_winsize(&self) -> Result<libc::winsize> {
let mut ws: libc::winsize = unsafe { mem::zeroed() };
let rc = unsafe { libc::ioctl(self.write_fd, libc::TIOCGWINSZ as IoctlReq, &mut ws) };
if rc != 0 {
return Err(io_err("ioctl(TIOCGWINSZ)"));
}
Ok(ws)
}
fn ioctl_size(&self) -> Result<Size> {
let ws = self.raw_winsize()?;
Ok(Size::new(ws.ws_col as i32, ws.ws_row as i32))
}
fn check_resize(&mut self) -> Option<Size> {
let now = self.ioctl_size().ok()?;
if now != self.seen_size && !now.is_empty() {
self.seen_size = now;
Some(now)
} else {
None
}
}
fn apply_raw_mode(&mut self) -> Result<libc::termios> {
let mut saved: libc::termios = unsafe { mem::zeroed() };
if unsafe { libc::tcgetattr(self.read_fd, &mut saved) } != 0 {
return Err(io_err("tcgetattr"));
}
let mut raw = saved;
raw.c_iflag &= !(libc::IGNBRK
| libc::BRKINT
| libc::PARMRK
| libc::ISTRIP
| libc::INLCR
| libc::IGNCR
| libc::ICRNL
| libc::IXON);
raw.c_oflag &= !libc::OPOST;
raw.c_lflag &= !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN);
raw.c_cflag &= !(libc::CSIZE | libc::PARENB);
raw.c_cflag |= libc::CS8;
raw.c_cc[libc::VMIN] = 0;
raw.c_cc[libc::VTIME] = 0;
if unsafe { libc::tcsetattr(self.read_fd, libc::TCSANOW, &raw) } != 0 {
return Err(io_err("tcsetattr(raw)"));
}
Ok(saved)
}
fn drain_winch_pipe(fd: RawFd) {
let mut buf = [0u8; 64];
loop {
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n <= 0 {
break;
}
}
}
}
impl Terminal for UnixTerminal {
fn enter(&mut self, opts: &EnterOptions) -> Result<()> {
if self.entered.is_some() {
return Ok(());
}
let saved = self.apply_raw_mode()?;
let winch = claim_winch(); self.seen_size = self.ioctl_size().unwrap_or(Size::ZERO);
if let Ok(mut g) = EMERGENCY.lock() {
*g = Some(EmergencySlot {
fd: self.write_fd,
termios: saved,
leave_bytes: opts.leave_bytes(),
});
}
self.entered = Some(EnteredState {
saved_termios: saved,
opts: *opts,
winch,
});
self.write(&opts.enter_bytes())?;
self.flush()?;
Ok(())
}
fn leave(&mut self) -> Result<()> {
let state = self.entered.take();
if state.is_none() && !self.cursor_styled && !self.title_pushed && !self.pixel_moused {
return Ok(());
}
let mut first_err: Option<Error> = None;
if let Some(s) = &state {
self.out.extend_from_slice(&s.opts.leave_bytes());
}
if self.pixel_moused {
self.out.extend_from_slice(verbs::PIXEL_MOUSE_OFF);
self.pixel_moused = false;
}
if self.cursor_styled {
self.out.extend_from_slice(verbs::CURSOR_STYLE_RESET);
self.cursor_styled = false;
}
if self.title_pushed {
self.out.extend_from_slice(verbs::TITLE_POP);
self.title_pushed = false;
}
if let Err(e) = self.flush() {
first_err.get_or_insert(e);
}
let Some(state) = state else {
return match first_err {
Some(e) => Err(e),
None => Ok(()),
};
};
if unsafe { libc::tcsetattr(self.read_fd, libc::TCSANOW, &state.saved_termios) } != 0 {
first_err.get_or_insert(io_err("tcsetattr(restore)"));
}
if let Some(claim) = state.winch {
release_winch(claim);
}
if let Ok(mut g) = EMERGENCY.lock() {
*g = None;
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
fn size(&mut self) -> Result<Size> {
self.ioctl_size()
}
fn read(&mut self, deadline: Option<Instant>) -> Result<TermRead<'_>> {
let winch_rd = self
.entered
.as_ref()
.and_then(|s| s.winch.as_ref())
.map(|w| w.pipe_rd);
loop {
let now = Instant::now();
let remaining = deadline.map(|d| d.saturating_duration_since(now));
let slice = match (remaining, winch_rd) {
(Some(r), Some(_)) => Some(r),
(Some(r), None) => Some(r.min(NO_SIGNAL_SLICE)),
(None, Some(_)) => None,
(None, None) => Some(NO_SIGNAL_SLICE),
};
let timeout_ms: libc::c_int = match slice {
None => -1,
Some(d) => d.as_millis().min(i32::MAX as u128) as libc::c_int,
};
let mut fds = [
libc::pollfd {
fd: self.read_fd,
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: winch_rd.unwrap_or(-1),
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: self.wake_rd.unwrap_or(-1),
events: libc::POLLIN,
revents: 0,
},
];
let rc = unsafe { libc::poll(fds.as_mut_ptr(), 3, timeout_ms) };
if rc < 0 {
if last_errno() == libc::EINTR {
if let Some(sz) = self.check_resize() {
return Ok(TermRead::Resize(sz));
}
continue;
}
return Err(io_err("poll"));
}
if fds[0].revents & libc::POLLNVAL != 0 {
let stdin_tty = unsafe { libc::isatty(libc::STDIN_FILENO) == 1 };
if stdin_tty && self.read_fd != libc::STDIN_FILENO {
self.read_fd = libc::STDIN_FILENO;
self.degraded =
Some("terminal fd not pollable (POLLNVAL); reading stdin instead");
continue; }
return Err(Error::Term(
"terminal read fd is not pollable (POLLNVAL) and no stdin tty \
fallback exists — refusing to go keyboard-dead. This is a \
terminal-emulator/platform quirk worth reporting (include \
your emulator + OS); rerun with stdin attached to the \
terminal as a workaround"
.into(),
));
}
if fds[1].revents & libc::POLLIN != 0 {
Self::drain_winch_pipe(fds[1].fd);
}
if let Some(sz) = self.check_resize() {
return Ok(TermRead::Resize(sz));
}
if fds[0].revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 {
let n = unsafe {
libc::read(
self.read_fd,
self.in_buf.as_mut_ptr() as *mut libc::c_void,
self.in_buf.len(),
)
};
if n < 0 {
let e = last_errno();
if e == libc::EINTR || e == libc::EAGAIN {
continue;
}
return Err(io_err("read(tty)"));
}
if n == 0 {
return Err(Error::Term(
"terminal closed (EOF on tty) — the emulator hung up; the app should exit"
.into(),
));
}
return Ok(TermRead::Input(&self.in_buf[..n as usize]));
}
if fds[2].revents & libc::POLLIN != 0 {
Self::drain_winch_pipe(fds[2].fd); return Ok(TermRead::Wake);
}
if let Some(d) = deadline {
if Instant::now() >= d {
return Ok(TermRead::Idle);
}
}
}
}
fn write(&mut self, bytes: &[u8]) -> Result<()> {
self.out.extend_from_slice(bytes);
if self.out.len() >= 1 << 16 {
self.flush()?;
}
Ok(())
}
fn flush(&mut self) -> Result<()> {
if self.out.is_empty() {
return Ok(());
}
let res = write_all_fd(self.write_fd, &self.out);
self.out.clear();
res
}
fn waker(&self) -> Option<TerminalWaker> {
self.waker.clone()
}
fn degraded(&self) -> Option<&'static str> {
self.degraded
}
fn is_tty(&self) -> bool {
unsafe { libc::isatty(self.write_fd) == 1 }
}
fn suspend(&mut self) -> Result<()> {
let opts = self.entered.as_ref().map(|s| s.opts);
match opts {
Some(opts) => {
self.leave()?;
Self::deliver_stop();
self.enter(&opts)
}
None => {
Self::deliver_stop();
Ok(())
}
}
}
fn set_cursor_style(&mut self, style: CursorStyle) -> Result<()> {
self.write(&verbs::cursor_style_bytes(style))?;
if style != CursorStyle::Default && !self.cursor_styled {
self.cursor_styled = true;
sys::append_emergency_leave(verbs::CURSOR_STYLE_RESET);
}
Ok(())
}
fn set_title(&mut self, title: &str) -> Result<()> {
if !self.title_pushed {
self.title_pushed = true;
self.write(verbs::TITLE_PUSH)?;
sys::append_emergency_leave(verbs::TITLE_POP);
}
self.write(&verbs::set_title_bytes(title))
}
fn set_pixel_mouse(&mut self, on: bool) -> Result<()> {
self.write(if on {
verbs::PIXEL_MOUSE_ON
} else {
verbs::PIXEL_MOUSE_OFF
})?;
if on && !self.pixel_moused {
sys::append_emergency_leave(verbs::PIXEL_MOUSE_OFF);
}
self.pixel_moused = on;
Ok(())
}
fn set_mouse_reporting(&mut self, on: bool) -> Result<()> {
let Some(state) = &self.entered else {
return Err(Error::Term(
"set_mouse_reporting outside a session — enter() first".into(),
));
};
let mode = state.opts.mouse;
self.write(if on {
mode.arm_bytes()
} else {
mode.disarm_bytes()
})
}
fn set_kitty_keyboard(&mut self, flags: super::options::KittyFlags) -> Result<()> {
let Some(state) = &mut self.entered else {
return Err(Error::Term(
"set_kitty_keyboard outside a session — enter() first".into(),
));
};
let prev = state.opts.kitty_keyboard;
if prev == flags {
return Ok(());
}
state.opts.kitty_keyboard = flags;
let mut bytes = Vec::with_capacity(16);
if !prev.is_empty() {
bytes.extend_from_slice(super::options::KittyFlags::POP_BYTES);
}
if !flags.is_empty() {
bytes.extend_from_slice(&flags.push_bytes());
}
if prev.is_empty() && !flags.is_empty() {
sys::prepend_emergency_leave(super::options::KittyFlags::POP_BYTES);
}
self.write(&bytes)
}
fn cell_pixel_size(&mut self) -> Option<PixelSize> {
let ws = self.raw_winsize().ok()?;
if ws.ws_xpixel == 0 || ws.ws_ypixel == 0 || ws.ws_col == 0 || ws.ws_row == 0 {
return None;
}
Some(PixelSize::new(
ws.ws_xpixel / ws.ws_col,
ws.ws_ypixel / ws.ws_row,
))
}
}
impl Drop for UnixTerminal {
fn drop(&mut self) {
let _ = self.leave();
if let Some(rd) = self.wake_rd {
unsafe { libc::close(rd) };
}
if self.owns_fds {
unsafe {
libc::close(self.read_fd);
if self.write_fd != self.read_fd {
libc::close(self.write_fd);
}
}
}
}
}
#[cfg(test)]
#[path = "unix_tests.rs"]
mod tests;