feat: honor async-backend with epoll/io_uring, fix last RV13 items

Resolves the 4 P3 items in docs/rewrite/notes/review-rv13-codex.md
(final-Z16.md):
- async-backend now takes effect like Ghostty: ghostty_os::xev ports
  libxev's backend detection (io_uring, then epoll); global init detects
  and logs it, the GTK startup applies the config with Zig's messages,
  and +version reports the real backend. The termio IO loop runs on a
  new reactor with epoll, io_uring (io-uring crate) and poll(2) (kept
  for macOS, where libxev uses kqueue). The 21 exec integration tests
  run under every backend; io_uring was exercised here.
- auto-update-channel defaults to the build's release channel.
- Windows error names: sharing violation -> AccessDenied, plus
  ERROR_FILE_EXISTS -> PathAlreadyExists and virus errors ->
  AntivirusInterference, mirroring std.os.windows.OpenFile's NTSTATUS
  mapping in Zig 0.15.2 (verified against its source by the
  orchestrator).
- Exact ports of std.fs.path.basenameWindows/basenamePosix for theme
  name validation (drive, UNC and device roots).

Test Plan:
- cargo test --workspace --no-fail-fast (3555 passed, exit 0)
- just check-macos (both), just check-windows, just check-wasm
- +show-config --default --docs vs /usr/bin/ghostty (only the
  build-specific gtk-opengl-debug line differs)
This commit is contained in:
ddidderr committed 2026-10-12 14:49:00 +02:00
1 parent 3ce3225642
commit 1bd918159a
18 files changed
+2012 -742

No files matched your search

+4
View File
@@ -18,6 +18,10 @@ ghostty-vt.workspace = true
log = "0.4.34"
thiserror = "2.0.21"
# io_uring backend of the IO thread's reactor (libxev `IO_Uring`).
[target.'cfg(target_os = "linux")'.dependencies]
io-uring = "0.7"
[target.'cfg(unix)'.dependencies]
libc = "0.2.190"
nix = { version = "0.31.3", features = ["signal"] }
+2
View File
@@ -29,6 +29,8 @@ pub mod message;
pub mod options;
#[cfg_attr(windows, path = "pty_windows.rs")]
pub mod pty;
#[cfg(unix)]
pub mod reactor;
pub mod shell_integration;
pub mod sinks;
pub mod state;
+553
View File
@@ -0,0 +1,553 @@
//! The IO thread's event demultiplexer (the part of libxev's `Loop` the IO
//! thread uses: fd readiness plus a timeout).
//!
//! libxev selects its backend at runtime (`ghostty_os::xev`, set from the
//! `async-backend` config in the GTK application): `io_uring` or `epoll` on
//! Linux. This mirrors that choice:
//!
//! * [`Backend::IoUring`]: one `IORING_OP_POLL_ADD` per interest (re-armed
//! after it fires, removed with `IORING_OP_POLL_REMOVE` when the interest
//! goes away) and an `EXT_ARG` timeout on the wait.
//! * [`Backend::Epoll`]: a persistent epoll instance whose registrations are
//! diffed against the interests every wait (level-triggered).
//! * `Poll`: plain `poll(2)`, used where libxev has a single, different
//! backend (kqueue on macOS) and cannot be selected.
#[cfg(target_os = "linux")]
use std::{collections::HashMap, os::fd::RawFd};
use std::{io, os::fd::BorrowedFd, time::Duration};
pub use ghostty_os::xev::Backend;
use rustix::event::{PollFd, PollFlags};
/// One thing to wait for: readability (or writability) of `fd`. `token` is
/// reported back by [`Reactor::wait`] and must be unique per call. The fd
/// must stay open while its token is passed to `wait`; when it is closed the
/// token must be left out of one `wait` before it is reused with another fd.
#[derive(Clone, Copy)]
pub struct Interest<'a> {
pub token: usize,
pub fd: BorrowedFd<'a>,
pub write: bool,
}
/// See the module docs.
pub enum Reactor {
Poll,
#[cfg(target_os = "linux")]
Epoll(EpollReactor),
#[cfg(target_os = "linux")]
IoUring(Box<UringReactor>),
}
impl Reactor {
/// Create the reactor for `backend`. Backends that do not exist for this
/// platform use `poll(2)`.
pub fn new(backend: Backend) -> io::Result<Reactor> {
match backend {
#[cfg(target_os = "linux")]
Backend::Epoll => Ok(Reactor::Epoll(EpollReactor::new()?)),
#[cfg(target_os = "linux")]
Backend::IoUring => Ok(Reactor::IoUring(Box::new(UringReactor::new()?))),
_ => Ok(Reactor::Poll),
}
}
/// A plain `poll(2)` reactor regardless of platform.
pub fn poll() -> Reactor {
Reactor::Poll
}
/// The name used in logs.
pub fn name(&self) -> &'static str {
match self {
Reactor::Poll => "poll",
#[cfg(target_os = "linux")]
Reactor::Epoll(_) => "epoll",
#[cfg(target_os = "linux")]
Reactor::IoUring(_) => "io_uring",
}
}
/// Wait until at least one interest is ready or `timeout` elapses
/// (`None` blocks forever). The tokens of the ready interests are
/// appended to `ready` (which is cleared first). An interrupted wait
/// returns with nothing ready.
pub fn wait(
&mut self,
interests: &[Interest<'_>],
timeout: Option<Duration>,
ready: &mut Vec<usize>,
) -> io::Result<()> {
ready.clear();
match self {
Reactor::Poll => poll_wait(interests, timeout, ready),
#[cfg(target_os = "linux")]
Reactor::Epoll(r) => r.wait(interests, timeout, ready),
#[cfg(target_os = "linux")]
Reactor::IoUring(r) => r.wait(interests, timeout, ready),
}
}
}
fn timespec(d: Duration) -> rustix::time::Timespec {
rustix::time::Timespec {
tv_sec: d.as_secs() as _,
tv_nsec: i64::from(d.subsec_nanos()) as _,
}
}
fn poll_wait(
interests: &[Interest<'_>],
timeout: Option<Duration>,
ready: &mut Vec<usize>,
) -> io::Result<()> {
let ts = timeout.map(timespec);
let mut fds: Vec<PollFd<'_>> = interests
.iter()
.map(|i| {
PollFd::from_borrowed_fd(
i.fd,
if i.write {
PollFlags::OUT
} else {
PollFlags::IN
},
)
})
.collect();
match rustix::event::poll(&mut fds, ts.as_ref()) {
Ok(_) => {}
Err(rustix::io::Errno::INTR) => return Ok(()),
Err(err) => return Err(err.into()),
}
for (i, fd) in interests.iter().zip(&fds) {
if !fd.revents().is_empty() {
ready.push(i.token);
}
}
Ok(())
}
/// Persistent epoll instance (level-triggered).
#[cfg(target_os = "linux")]
pub struct EpollReactor {
epoll: std::os::fd::OwnedFd,
/// token -> (raw fd, write)
registered: HashMap<usize, (RawFd, bool)>,
}
#[cfg(target_os = "linux")]
impl EpollReactor {
fn new() -> io::Result<EpollReactor> {
use rustix::event::epoll;
Ok(EpollReactor {
epoll: epoll::create(epoll::CreateFlags::CLOEXEC)?,
registered: HashMap::new(),
})
}
#[allow(unsafe_code)]
fn sync(&mut self, interests: &[Interest<'_>]) -> io::Result<()> {
use rustix::event::epoll;
// Drop registrations that are gone or changed.
let stale: Vec<usize> = self
.registered
.iter()
.filter(|(token, (raw, write))| {
!interests.iter().any(|i| {
i.token == **token
&& std::os::fd::AsRawFd::as_raw_fd(&i.fd) == *raw
&& i.write == *write
})
})
.map(|(t, _)| *t)
.collect();
for token in stale {
if let Some((raw, _)) = self.registered.remove(&token) {
// SAFETY: the fd may already have been closed by its owner;
// `epoll_ctl(DEL)` then fails with EBADF/ENOENT, which is
// ignored. The borrow does not outlive this call and nothing
// is read from or written to the fd.
let fd = unsafe { BorrowedFd::borrow_raw(raw) };
let _ = epoll::delete(&self.epoll, fd);
}
}
for i in interests {
if self.registered.contains_key(&i.token) {
continue;
}
let flags = if i.write {
epoll::EventFlags::OUT
} else {
epoll::EventFlags::IN
};
epoll::add(
&self.epoll,
i.fd,
epoll::EventData::new_u64(i.token as u64),
flags,
)?;
self.registered
.insert(i.token, (std::os::fd::AsRawFd::as_raw_fd(&i.fd), i.write));
}
Ok(())
}
fn wait(
&mut self,
interests: &[Interest<'_>],
timeout: Option<Duration>,
ready: &mut Vec<usize>,
) -> io::Result<()> {
use rustix::event::epoll;
self.sync(interests)?;
// (`Event` is not `Send`, so it cannot live in the reactor.)
let mut events: Vec<epoll::Event> = Vec::with_capacity(16);
match epoll::wait(
&self.epoll,
rustix::buffer::spare_capacity(&mut events),
timeout.map(timespec).as_ref(),
) {
Ok(_) => {}
Err(rustix::io::Errno::INTR) => return Ok(()),
Err(err) => return Err(err.into()),
}
for ev in &events {
let token = ev.data.u64() as usize;
if self.registered.contains_key(&token) {
ready.push(token);
}
}
Ok(())
}
}
/// The user_data of `IORING_OP_POLL_REMOVE` submissions (ids start at 1).
#[cfg(target_os = "linux")]
const REMOVE_ID: u64 = 0;
/// io_uring based poller (`IORING_OP_POLL_ADD` one-shot polls).
#[cfg(target_os = "linux")]
pub struct UringReactor {
ring: io_uring::IoUring,
/// token -> (raw fd, write, id of the armed poll)
armed: HashMap<usize, (RawFd, bool, u64)>,
next_id: u64,
}
#[cfg(target_os = "linux")]
impl UringReactor {
fn new() -> io::Result<UringReactor> {
// Same ring size as libxev's default.
Ok(UringReactor {
ring: io_uring::IoUring::new(256)?,
armed: HashMap::new(),
next_id: 1,
})
}
/// Queue an entry, flushing the submission queue first if it is full.
#[allow(unsafe_code)]
fn push(&mut self, entry: &io_uring::squeue::Entry) -> io::Result<()> {
// SAFETY: poll add/remove entries reference no user memory: the fd
// is a plain descriptor number that is valid for the duration of the
// `wait` call that submits it (the caller borrows it), and the
// user_data is an opaque id.
unsafe {
if self.ring.submission().push(entry).is_err() {
self.ring.submit()?;
self.ring
.submission()
.push(entry)
.map_err(|_| io::Error::other("io_uring submission queue full"))?;
}
}
Ok(())
}
fn sync(&mut self, interests: &[Interest<'_>]) -> io::Result<()> {
use io_uring::{opcode, types};
// Cancel polls whose interest is gone or changed.
let stale: Vec<usize> = self
.armed
.iter()
.filter(|(token, (raw, write, _))| {
!interests.iter().any(|i| {
i.token == **token
&& std::os::fd::AsRawFd::as_raw_fd(&i.fd) == *raw
&& i.write == *write
})
})
.map(|(t, _)| *t)
.collect();
for token in stale {
if let Some((_, _, id)) = self.armed.remove(&token) {
let entry = opcode::PollRemove::new(id).build().user_data(REMOVE_ID);
self.push(&entry)?;
}
}
// Arm the new ones.
for i in interests {
if self.armed.contains_key(&i.token) {
continue;
}
let raw = std::os::fd::AsRawFd::as_raw_fd(&i.fd);
let mask = if i.write { libc::POLLOUT } else { libc::POLLIN } as u32;
let id = self.next_id;
self.next_id += 1;
let entry = opcode::PollAdd::new(types::Fd(raw), mask)
.build()
.user_data(id);
self.push(&entry)?;
self.armed.insert(i.token, (raw, i.write, id));
}
Ok(())
}
fn reap(&mut self, interests: &[Interest<'_>], ready: &mut Vec<usize>) {
let mut cq = self.ring.completion();
cq.sync();
for cqe in &mut cq {
let id = cqe.user_data();
if id == REMOVE_ID {
continue;
}
let Some(token) = self
.armed
.iter()
.find(|(_, (_, _, armed_id))| *armed_id == id)
.map(|(t, _)| *t)
else {
// Completion of a poll we already cancelled.
continue;
};
// Polls are one-shot: it must be armed again next time.
self.armed.remove(&token);
if interests.iter().any(|i| i.token == token) {
ready.push(token);
}
}
}
fn wait(
&mut self,
interests: &[Interest<'_>],
timeout: Option<Duration>,
ready: &mut Vec<usize>,
) -> io::Result<()> {
use io_uring::types;
self.sync(interests)?;
// Results already waiting (e.g. from a previous partial reap).
let res = match timeout {
None => self.ring.submit_and_wait(1),
Some(d) => {
let ts = types::Timespec::new()
.sec(d.as_secs())
.nsec(d.subsec_nanos());
let args = types::SubmitArgs::new().timespec(&ts);
self.ring.submitter().submit_with_args(1, &args)
}
};
match res {
Ok(_) => {}
Err(e) => match e.raw_os_error() {
Some(libc::ETIME | libc::EINTR | libc::EBUSY) => {}
_ => return Err(e),
},
}
self.reap(interests, ready);
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::os::fd::{AsFd, OwnedFd};
use super::*;
fn backends() -> Vec<Backend> {
#[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
let mut v = vec![Backend::Kqueue]; // -> poll
#[cfg(target_os = "linux")]
{
v.push(Backend::Epoll);
if ghostty_os::xev::available(Backend::IoUring) {
v.push(Backend::IoUring);
} else {
eprintln!("SKIP io_uring reactor: unavailable");
}
}
v
}
fn pipe() -> (OwnedFd, OwnedFd) {
rustix::pipe::pipe().unwrap()
}
#[test]
fn times_out_with_nothing_ready() {
for be in backends() {
let mut r = Reactor::new(be).unwrap();
let (rd, _wr) = pipe();
let mut ready = vec![];
let start = std::time::Instant::now();
r.wait(
&[Interest {
token: 7,
fd: rd.as_fd(),
write: false,
}],
Some(Duration::from_millis(30)),
&mut ready,
)
.unwrap();
assert!(ready.is_empty(), "{}", r.name());
assert!(start.elapsed() >= Duration::from_millis(25), "{}", r.name());
}
}
#[test]
fn readable_write_and_rearm() {
for be in backends() {
let mut r = Reactor::new(be).unwrap();
let (rd, wr) = pipe();
let ints = [
Interest {
token: 1,
fd: rd.as_fd(),
write: false,
},
Interest {
token: 2,
fd: wr.as_fd(),
write: true,
},
];
let mut ready = vec![];
// The write end is writable immediately; the read end is not.
r.wait(&ints, Some(Duration::from_millis(500)), &mut ready)
.unwrap();
assert_eq!(ready, vec![2], "{}", r.name());
rustix::io::write(&wr, b"x").unwrap();
// Level-triggered: still reported on every wait until drained.
for _ in 0..3 {
r.wait(&ints[..1], Some(Duration::from_millis(500)), &mut ready)
.unwrap();
assert_eq!(ready, vec![1], "{}", r.name());
}
let mut buf = [0u8; 8];
rustix::io::read(&rd, &mut buf).unwrap();
r.wait(&ints[..1], Some(Duration::from_millis(20)), &mut ready)
.unwrap();
assert!(ready.is_empty(), "{}", r.name());
}
}
#[test]
fn dropped_interest_is_not_reported_and_can_return() {
for be in backends() {
let mut r = Reactor::new(be).unwrap();
let (rd, wr) = pipe();
let (rd2, wr2) = pipe();
let both = [
Interest {
token: 1,
fd: rd.as_fd(),
write: false,
},
Interest {
token: 2,
fd: rd2.as_fd(),
write: false,
},
];
let mut ready = vec![];
r.wait(&both, Some(Duration::from_millis(10)), &mut ready)
.unwrap();
rustix::io::write(&wr, b"x").unwrap();
rustix::io::write(&wr2, b"y").unwrap();
// Only token 2 is wanted now.
r.wait(&both[1..], Some(Duration::from_millis(500)), &mut ready)
.unwrap();
assert_eq!(ready, vec![2], "{}", r.name());
// Token 1 comes back and is still readable.
r.wait(&both[..1], Some(Duration::from_millis(500)), &mut ready)
.unwrap();
assert_eq!(ready, vec![1], "{}", r.name());
}
}
#[test]
fn wakes_from_another_thread() {
for be in backends() {
let mut r = Reactor::new(be).unwrap();
let (rd, wr) = pipe();
let h = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(30));
rustix::io::write(&wr, b"x").unwrap();
wr
});
let mut ready = vec![];
r.wait(
&[Interest {
token: 3,
fd: rd.as_fd(),
write: false,
}],
None,
&mut ready,
)
.unwrap();
assert_eq!(ready, vec![3], "{}", r.name());
drop(h.join().unwrap());
}
}
#[test]
fn closed_fd_registration_is_replaced() {
for be in backends() {
let mut r = Reactor::new(be).unwrap();
let (rd, wr) = pipe();
let mut ready = vec![];
r.wait(
&[Interest {
token: 1,
fd: rd.as_fd(),
write: false,
}],
Some(Duration::from_millis(5)),
&mut ready,
)
.unwrap();
drop(rd);
drop(wr);
// Contract: a token whose fd was closed is omitted from one wait
// before it may be used with a new fd.
r.wait(&[], Some(Duration::from_millis(1)), &mut ready)
.unwrap();
let (rd, wr) = pipe();
rustix::io::write(&wr, b"x").unwrap();
r.wait(
&[Interest {
token: 1,
fd: rd.as_fd(),
write: false,
}],
Some(Duration::from_millis(500)),
&mut ready,
)
.unwrap();
assert_eq!(ready, vec![1], "{}", r.name());
}
}
}
+121 -60
View File
@@ -12,7 +12,8 @@
//!
//! # Event loop
//!
//! libxev is replaced by a single `poll(2)` loop over: the mailbox wakeup
//! libxev is replaced by a single readiness loop (see [`crate::reactor`]:
//! `io_uring`, `epoll` or `poll(2)` as selected by `async-backend`) over: the mailbox wakeup
//! eventfd, the stop eventfd, the child's pidfd (or the Flatpak exit
//! wakeup), the pty (`POLLOUT`, only while writes are queued) and a set of
//! deadlines (selection scroll, resize coalescing, synchronized-output reset,
@@ -27,9 +28,9 @@ use ghostty_apprt::surface::Message as SurfaceMessage;
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::size::Size;
use ghostty_vt::{csi, modes::Mode};
#[cfg(unix)]
use rustix::event::{PollFd, PollFlags};
#[cfg(unix)]
use crate::reactor::{Backend, Interest, Reactor};
use crate::{
backend::ThreadData as BackendThreadData,
message::Message,
@@ -43,6 +44,17 @@ use crate::{
#[path = "thread_windows.rs"]
mod windows_loop;
#[cfg(unix)]
const TOKEN_MAILBOX: usize = 0;
#[cfg(unix)]
const TOKEN_STOP: usize = 1;
#[cfg(unix)]
const TOKEN_PROCESS: usize = 2;
#[cfg(unix)]
const TOKEN_FLATPAK: usize = 3;
#[cfg(unix)]
const TOKEN_PTY_OUT: usize = 4;
/// The number of milliseconds to coalesce certain messages like resize for.
/// Not all message types are coalesced.
pub const COALESCE_MIN_MS: u64 = 25;
@@ -87,6 +99,10 @@ pub struct Thread {
flags: Flags,
/// The event demultiplexer (the xev loop). Taken out while running.
#[cfg(unix)]
reactor: Option<Reactor>,
/// The surface dimensions recorded in crash reports.
pub crash_dims: Option<std::sync::Arc<ghostty_crash::SurfaceDims>>,
}
@@ -96,7 +112,31 @@ impl Thread {
/// up all the internal state necessary prior to starting the thread. It
/// is up to the caller to run [`Thread::thread_main`] on a thread.
pub fn new() -> io::Result<Thread> {
#[cfg(unix)]
{
Thread::with_backend(ghostty_os::xev::backend())
}
#[cfg(not(unix))]
{
Thread::new_()
}
}
/// Like [`Thread::new`] but with an explicit event loop backend instead
/// of the process-wide selection (`ghostty_os::xev`).
#[cfg(unix)]
pub fn with_backend(backend: Backend) -> io::Result<Thread> {
let reactor = Reactor::new(backend)?;
log::debug!("io thread event loop backend={}", reactor.name());
let mut thread = Thread::new_()?;
thread.reactor = Some(reactor);
Ok(thread)
}
fn new_() -> io::Result<Thread> {
Ok(Thread {
#[cfg(unix)]
reactor: None,
stop: Wakeup::new()?,
scroll_active: false,
scroll_deadline: None,
@@ -156,30 +196,37 @@ impl Thread {
#[cfg(unix)]
fn drain_until_stop(&mut self, io: &Termio) {
let mailbox = io.mailbox().clone();
let Some(mut reactor) = self.reactor.take() else {
return;
};
let mut ready = Vec::new();
loop {
let mut fds = [
PollFd::from_borrowed_fd(mailbox.wakeup().as_fd(), PollFlags::IN),
PollFd::from_borrowed_fd(self.stop.as_fd(), PollFlags::IN),
let interests = [
Interest {
token: TOKEN_MAILBOX,
fd: mailbox.wakeup().as_fd(),
write: false,
},
Interest {
token: TOKEN_STOP,
fd: self.stop.as_fd(),
write: false,
},
];
match rustix::event::poll(&mut fds, None) {
Ok(_) => {}
Err(rustix::io::Errno::INTR) => continue,
Err(err) => {
log::error!("failed to poll for draining err={err}");
return;
}
if let Err(err) = reactor.wait(&interests, None, &mut ready) {
log::error!("failed to poll for draining err={err}");
break;
}
let stop = fds[1].revents().contains(PollFlags::IN);
let msgs = fds[0].revents().contains(PollFlags::IN);
if msgs {
if ready.contains(&TOKEN_MAILBOX) {
mailbox.wakeup().drain();
while mailbox.pop().is_some() {}
}
if stop {
if ready.contains(&TOKEN_STOP) {
self.stop.drain();
return;
break;
}
}
self.reactor = Some(reactor);
}
fn next_deadline(&self, td: &ThreadData) -> Option<Instant> {
@@ -199,72 +246,86 @@ impl Thread {
/// The event loop. Returns when stopped.
#[cfg(unix)]
fn run(&mut self, io: &mut Termio, td: &mut ThreadData) {
let Some(mut reactor) = self.reactor.take() else {
log::error!("io thread has no event loop");
return;
};
self.run_loop(&mut reactor, io, td);
self.reactor = Some(reactor);
}
#[cfg(unix)]
fn run_loop(&mut self, reactor: &mut Reactor, io: &mut Termio, td: &mut ThreadData) {
let mailbox = io.mailbox().clone();
let mut ready: Vec<usize> = Vec::new();
loop {
let timeout = self.next_deadline(td).map(|d| {
let dur = d.saturating_duration_since(Instant::now());
rustix::time::Timespec {
tv_sec: dur.as_secs() as _,
tv_nsec: i64::from(dur.subsec_nanos()) as _,
}
});
let timeout = self
.next_deadline(td)
.map(|d| d.saturating_duration_since(Instant::now()));
// Build the poll set: fixed entries first, optional ones after.
let mut fds: Vec<PollFd<'_>> = Vec::with_capacity(5);
fds.push(PollFd::from_borrowed_fd(
mailbox.wakeup().as_fd(),
PollFlags::IN,
));
fds.push(PollFd::from_borrowed_fd(self.stop.as_fd(), PollFlags::IN));
// Build the interest set: fixed entries first, optional ones after.
let mut interests: Vec<Interest<'_>> = Vec::with_capacity(5);
interests.push(Interest {
token: TOKEN_MAILBOX,
fd: mailbox.wakeup().as_fd(),
write: false,
});
interests.push(Interest {
token: TOKEN_STOP,
fd: self.stop.as_fd(),
write: false,
});
let exec = td.backend.as_exec();
let proc_idx = exec.process_fd().map(|fd| {
fds.push(PollFd::from_borrowed_fd(fd, PollFlags::IN));
fds.len() - 1
});
let fp_idx = exec.flatpak_fd().map(|fd| {
fds.push(PollFd::from_borrowed_fd(fd, PollFlags::IN));
fds.len() - 1
});
let out_idx = exec.wants_write().then(|| {
fds.push(PollFd::from_borrowed_fd(exec.pty_fd(), PollFlags::OUT));
fds.len() - 1
});
match rustix::event::poll(&mut fds, timeout.as_ref()) {
Ok(_) => {}
Err(rustix::io::Errno::INTR) => continue,
Err(err) => {
log::error!("io thread poll failed err={err}");
return;
}
if let Some(fd) = exec.process_fd() {
interests.push(Interest {
token: TOKEN_PROCESS,
fd,
write: false,
});
}
if let Some(fd) = exec.flatpak_fd() {
interests.push(Interest {
token: TOKEN_FLATPAK,
fd,
write: false,
});
}
if exec.wants_write() {
interests.push(Interest {
token: TOKEN_PTY_OUT,
fd: exec.pty_fd(),
write: true,
});
}
let rev: Vec<PollFlags> = fds.iter().map(PollFd::revents).collect();
drop(fds);
let ready = |i: usize| !rev[i].is_empty();
if let Err(err) = reactor.wait(&interests, timeout, &mut ready) {
log::error!("io thread poll failed err={err}");
return;
}
drop(interests);
// Stop requested.
if ready(1) {
if ready.contains(&TOKEN_STOP) {
self.stop.drain();
return;
}
// The mailbox has messages.
if ready(0) {
if ready.contains(&TOKEN_MAILBOX) {
// When we wake up, we check the mailbox. Mailbox producers
// should wake up our thread after publishing.
mailbox.wakeup().drain();
self.drain_mailbox(io, td);
}
if proc_idx.is_some_and(ready) {
if ready.contains(&TOKEN_PROCESS) {
td.backend.as_exec_mut().on_process_ready(None);
}
if fp_idx.is_some_and(ready) {
if ready.contains(&TOKEN_FLATPAK) {
td.backend.as_exec_mut().on_flatpak_ready();
}
if out_idx.is_some_and(ready) {
if ready.contains(&TOKEN_PTY_OUT) {
td.backend.as_exec_mut().flush_writes();
}
+17 -601
View File
@@ -3,608 +3,24 @@
//! End-to-end tests: real child processes on a real pty, driven through
//! `Termio` + `Thread` (IO thread) + the pty read thread, against a real
//! `Terminal`.
//!
//! The whole suite (`exec_integration/suite.rs`) runs once per IO thread
//! event loop backend (`async-backend`): `epoll`, `io_uring` (skipped, with a
//! message, where the kernel/sandbox denies it) and the portable `poll(2)`
//! loop used where libxev has a single different backend (kqueue).
use std::{
sync::{
Arc,
Mutex,
atomic::{AtomicUsize, Ordering},
},
thread::JoinHandle,
time::{Duration, Instant},
};
use crossbeam_channel::{Receiver, Sender, unbounded};
use ghostty_apprt::surface::Message as SurfaceMessage;
use ghostty_config::{
command::Command as ConfigCommand,
config::Config,
io::ReadableIO,
types_b::{LinuxCgroup, ShellIntegration},
};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::{
message::Message as RendererMessage,
size::{CellSize, Padding, ScreenSize, Size},
};
use ghostty_termio::{
Mailbox,
Options,
RendererMailbox,
SharedState,
SharedStateExt,
SurfaceMailbox,
Termio,
Thread,
Wakeup,
backend::{self, Backend},
exec,
message::Message,
termio::DerivedConfig,
};
struct SurfaceRec(Sender<SurfaceMessage>);
impl SurfaceMailbox for SurfaceRec {
fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize {
let _ = self.0.send(msg);
1
}
}
#[derive(Default)]
struct RendererRec {
resizes: Mutex<Vec<Size>>,
reset_blinks: AtomicUsize,
}
impl RendererMailbox for RendererRec {
fn push(&self, msg: RendererMessage, _t: Timeout) -> usize {
match msg {
RendererMessage::Resize(s) => self.resizes.lock().unwrap().push(s),
RendererMessage::ResetCursorBlink => {
self.reset_blinks.fetch_add(1, Ordering::SeqCst);
}
_ => {}
macro_rules! suite {
($name:ident, $be:expr) => {
mod $name {
const UNDER_TEST: ghostty_os::xev::Backend = $be;
include!("exec_integration/suite.rs");
}
1
}
};
}
fn size(cols: u32, rows: u32) -> Size {
Size {
screen: ScreenSize {
width: cols * 10,
height: rows * 20,
},
cell: CellSize {
width: 10,
height: 20,
},
padding: Padding::default(),
}
}
struct Fixture {
state: SharedState,
mailbox: Mailbox,
stop: Wakeup,
thread: Option<JoinHandle<()>>,
surface: Receiver<SurfaceMessage>,
renderer: Arc<RendererRec>,
renderer_wakeup: Wakeup,
}
fn base_config() -> Config {
Config::default()
}
fn start(command: ConfigCommand, tweak: impl FnOnce(&mut Config)) -> Fixture {
let mut config = base_config();
tweak(&mut config);
let mut ecfg = exec::Config::new(
ghostty_os::env::get_env_map(),
ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"),
);
ecfg.command = Some(command);
ecfg.shell_integration = ShellIntegration::None;
// The default config has `linux-cgroup = single-instance` +
// `gtk-single-instance = detect` which only makes sense after the apprt
// resolved it.
ecfg.rt_pre_exec_info.linux_cgroup = LinuxCgroup::Never;
ecfg.rt_post_fork_info.linux_cgroup = LinuxCgroup::Never;
let mailbox = Mailbox::new().unwrap();
let (tx, surface) = unbounded();
let renderer = Arc::new(RendererRec::default());
let renderer_wakeup = Wakeup::new().unwrap();
let termio = Termio::init(Options {
size: size(80, 30),
full_config: &config,
config: DerivedConfig::new(&config),
backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(),
mailbox: mailbox.clone(),
renderer_wakeup: renderer_wakeup.clone(),
renderer_mailbox: renderer.clone(),
surface_mailbox: Arc::new(SurfaceRec(tx)),
})
.unwrap();
let state = Arc::clone(termio.renderer_state());
let thread = Thread::new().unwrap();
let stop = thread.stop_handle();
let handle = std::thread::Builder::new()
.name("io".into())
.spawn(move || thread.thread_main(termio))
.unwrap();
Fixture {
state,
mailbox,
stop,
thread: Some(handle),
surface,
renderer,
renderer_wakeup,
}
}
fn sh(script: &str) -> ConfigCommand {
ConfigCommand::Direct(vec!["/bin/sh".into(), "-c".into(), script.into()])
}
impl Fixture {
fn text(&self) -> String {
self.state.lock_terminal().plain_string().unwrap()
}
fn send(&self, msg: Message) {
self.mailbox.send(msg);
self.mailbox.notify();
}
fn write(&self, data: &[u8]) {
self.send(Message::write_req(data));
}
/// Wait until `f` returns Some or time out (panics).
fn wait_for<T>(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option<T>) -> T {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(v) = f(self) {
return v;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {what}; terminal text = {:?}",
self.text()
);
std::thread::sleep(Duration::from_millis(10));
}
}
fn wait_text(&self, needle: &str) {
self.wait_for(&format!("text {needle:?}"), |f| {
f.text().contains(needle).then_some(())
});
}
/// Wait for the ChildExited surface message, returning all messages
/// received up to and including it.
fn wait_exit(&self) -> (u32, u64, Vec<SurfaceMessage>) {
let deadline = Instant::now() + Duration::from_secs(10);
let mut seen = Vec::new();
loop {
let left = deadline.saturating_duration_since(Instant::now());
let msg = self
.surface
.recv_timeout(left)
.unwrap_or_else(|_| panic!("timed out waiting for child exit; seen={seen:?}"));
if let SurfaceMessage::ChildExited(c) = msg {
return (c.exit_code, c.runtime_ms, seen);
}
seen.push(msg);
}
}
fn stop(&mut self) {
self.stop.notify().unwrap();
if let Some(t) = self.thread.take() {
t.join().unwrap();
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = self.stop.notify();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
#[test]
fn printf_output_reaches_terminal_and_exit_is_reported() {
let mut f = start(sh("printf 'hello\\033[31mred'"), |_| {});
let (code, runtime_ms, _) = f.wait_exit();
assert_eq!(code, 0);
assert!(runtime_ms < 10_000);
// The reader thread may still be draining the pty after the exit event.
f.wait_text("hellored");
assert_eq!("hellored", f.text());
// The SGR was applied: the cursor pen is red (palette 1).
{
let t = f.state.lock_terminal();
let style = t.screen().cursor.style;
assert!(
!matches!(style.fg_color, ghostty_terminal::style::Color::None),
"pen fg should be set after \\e[31m: {style:?}"
);
assert_eq!(t.screen().cursor.x, 8);
}
// The renderer was woken up and told to reset the cursor blink.
assert!(f.renderer.reset_blinks.load(Ordering::SeqCst) >= 1);
assert!(f.renderer_wakeup_pending());
f.stop();
}
impl Fixture {
fn renderer_wakeup_pending(&self) -> bool {
self.renderer_wakeup.drain()
}
}
#[test]
fn nonzero_exit_code_is_reported() {
let mut f = start(sh("exit 3"), |_| {});
let (code, _, _) = f.wait_exit();
assert_eq!(code, 3);
f.stop();
}
#[test]
fn killed_by_signal_reports_signal_number() {
let mut f = start(sh("kill -KILL $$"), |_| {});
let (code, _, _) = f.wait_exit();
assert_eq!(code, 9);
f.stop();
}
#[test]
fn exec_failure_exits_quickly_with_error() {
// Abnormal exit detection itself lives in the Surface (it compares
// `runtime_ms` against `abnormal-command-exit-runtime`); termio must
// report a tiny runtime and the exit code.
let mut f = start(
ConfigCommand::Direct(vec!["/nonexistent/definitely-not-here".into()]),
|_| {},
);
let (code, runtime_ms, _) = f.wait_exit();
assert_eq!(code, 1);
assert!(runtime_ms < 5_000, "runtime {runtime_ms}");
f.stop();
}
#[test]
fn writes_reach_the_child_and_are_echoed() {
let mut f = start(sh("cat"), |_| {});
f.write(b"abc\n");
// Once by the tty echo and once by cat.
f.wait_for("two echoes", |f| {
(f.text().matches("abc").count() >= 2).then_some(())
});
f.stop();
}
#[test]
fn large_write_with_backpressure_is_delivered_in_order() {
// 256 KiB through a raw-mode `cat`: exercises the non-blocking write
// queue (the pty buffer is far smaller) and the output reader at the
// same time (cat would otherwise block on a full pty).
let mut f = start(
sh("stty raw -echo; echo ready; head -c 262144 | wc -c; echo done"),
|_| {},
);
// Writing before `stty raw` applies would go through the cooked-mode
// line discipline, which silently drops bytes past MAX_CANON (4095)
// on a line without a newline.
f.wait_text("ready");
let chunk = vec![b'x'; 4096];
for _ in 0..64 {
f.write(&chunk);
}
f.wait_text("262144");
f.wait_text("done");
f.stop();
}
#[test]
fn linefeed_mode_translates_cr() {
let mut f = start(sh("stty raw -echo; cat -v"), |_| {});
// Give stty a moment so the first byte is not eaten by cooked mode.
std::thread::sleep(Duration::from_millis(300));
f.send(Message::LinefeedMode(true));
f.write(b"a\r");
f.wait_text("a^M");
// cat -v got "a\r\n", so it printed a newline after the ^M.
f.wait_for("cursor on second row", |f| {
(f.state.lock_terminal().screen().cursor.y == 1).then_some(())
});
f.stop();
}
#[test]
fn terminal_query_reply_is_written_back_to_the_pty() {
// DA1 through the whole stack: child -> pty -> reader thread -> stream
// handler -> termio mailbox -> IO thread -> pty -> child.
let mut f = start(
sh("stty raw -echo; printf '\\033[c'; head -c 12 | cat -v; echo"),
|_| {},
);
f.wait_text("^[[?62;22;52c");
f.stop();
}
#[test]
fn initial_input_is_written_after_start() {
let mut f = start(sh("cat"), |c| {
c.input.list.push(ReadableIO::Raw("hi there\\n".into()));
});
f.wait_for("echo of input", |f| {
(f.text().matches("hi there").count() >= 2).then_some(())
});
f.stop();
}
#[test]
fn missing_input_file_shows_error_and_thread_keeps_draining() {
let mut f = start(sh("true"), |c| {
c.input.list.push(ReadableIO::Path(
ghostty_os::osbytes::EncodedOs::from_plain_text("/nonexistent/ghostty-input"),
));
});
f.wait_text("A configured `input` path was not found");
assert!(
!f.state
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::CursorVisible)
);
// Messages are swallowed in drain mode and stop still works.
f.write(b"ignored");
std::thread::sleep(Duration::from_millis(50));
f.stop();
}
#[test]
fn resize_messages_are_coalesced_and_applied_to_the_pty() {
let mut f = start(sh("sleep 0.7; stty size"), |_| {});
std::thread::sleep(Duration::from_millis(100));
for i in 0..20u32 {
f.send(Message::Resize(size(60 + i % 5, 20 + i)));
}
f.send(Message::Resize(size(100, 40)));
f.wait_text("40 100");
{
let t = f.state.lock_terminal();
assert_eq!((t.cols, t.rows), (100, 40));
assert_eq!(t.width_px, 1000);
assert_eq!(t.height_px, 800);
}
let n = f.renderer.resizes.lock().unwrap().len();
assert!((1..21).contains(&n), "expected coalescing, got {n} resizes");
assert_eq!(
*f.renderer.resizes.lock().unwrap().last().unwrap(),
size(100, 40)
);
f.stop();
}
#[test]
fn synchronized_output_is_force_reset_by_timer() {
let mut f = start(sh("printf '\\033[?2026h'; sleep 5"), |_| {});
f.wait_for("sync output on", |f| {
f.state
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::SynchronizedOutput)
.then_some(())
});
let started = Instant::now();
f.wait_for("sync output reset", |f| {
(!f.state
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::SynchronizedOutput))
.then_some(())
});
let waited = started.elapsed();
assert!(
waited < Duration::from_millis(2500),
"reset took {waited:?}"
);
f.stop();
}
#[test]
fn password_input_is_detected_via_termios_polling() {
let mut f = start(sh("stty -echo; sleep 3"), |_| {});
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let left = deadline.saturating_duration_since(Instant::now());
match f.surface.recv_timeout(left) {
Ok(SurfaceMessage::PasswordInput(true)) => break,
Ok(_) => {}
Err(err) => panic!("never saw PasswordInput(true): {err}"),
}
}
f.stop();
}
#[test]
fn selection_scroll_timer_ticks_until_stopped() {
let mut f = start(sh("sleep 3"), |_| {});
f.send(Message::SelectionScroll(true));
let mut ticks = 0;
let deadline = Instant::now() + Duration::from_secs(5);
while ticks < 3 {
let left = deadline.saturating_duration_since(Instant::now());
if let Ok(SurfaceMessage::SelectionScrollTick(true)) = f.surface.recv_timeout(left) {
ticks += 1;
}
assert!(Instant::now() < deadline);
}
f.send(Message::SelectionScroll(false));
let mut saw_false = false;
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
if let Ok(SurfaceMessage::SelectionScrollTick(false)) =
f.surface.recv_timeout(Duration::from_millis(100))
{
saw_false = true;
break;
}
}
assert!(saw_false);
// And then it stops ticking.
std::thread::sleep(Duration::from_millis(100));
while f.surface.try_recv().is_ok() {}
std::thread::sleep(Duration::from_millis(100));
assert!(f.surface.try_recv().is_err());
f.stop();
}
#[test]
fn focus_events_are_sent_when_enabled() {
let mut f = start(
sh("stty raw -echo; printf '\\033[?1004h'; head -c 3 | cat -v; echo"),
|_| {},
);
f.wait_for("focus mode", |f| {
f.state
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::FocusEvent)
.then_some(())
});
f.send(Message::Focused(true));
f.wait_text("^[[I");
f.stop();
}
#[test]
fn size_report_mode_2048_and_xtwinops() {
let mut f = start(
sh("stty raw -echo; printf '\\033[18t'; head -c 10 | cat -v; echo"),
|_| {},
);
// CSI 18 t -> ESC [ 8 ; 30 ; 80 t
f.wait_text("^[[8;30;80t");
f.stop();
}
#[test]
fn clear_screen_message_at_prompt_clears_and_sends_formfeed() {
let mut f = start(
sh("stty raw -echo; printf 'one\\ntwo\\n'; head -c 1 | cat -v; echo"),
|_| {},
);
f.wait_text("two");
f.send(Message::ClearScreen { history: false });
// Not at a prompt (no shell integration marks): rows above the cursor
// are erased and no form feed is sent.
f.wait_for("rows above cursor erased", |f| {
let t = f.text();
(!t.contains("one")).then_some(())
});
f.stop();
}
#[test]
fn stop_kills_the_child_process_group() {
let dir = std::env::temp_dir().join(format!("ghostty-termio-pid-{}", std::process::id()));
let pidfile = dir.display().to_string();
let mut f = start(sh(&format!("echo $$ > {pidfile}; exec sleep 60")), |_| {});
let pid: i32 = f.wait_for("pid file", |_| {
std::fs::read_to_string(&dir)
.ok()
.and_then(|s| s.trim().parse().ok())
});
let started = Instant::now();
f.stop();
assert!(started.elapsed() < Duration::from_secs(5));
let alive = rustix::process::test_kill_process(rustix::process::Pid::from_raw(pid).unwrap());
assert!(alive.is_err(), "child {pid} is still alive");
let _ = std::fs::remove_file(&dir);
}
#[test]
fn writes_after_exit_are_ignored_and_thread_stays_alive_until_stopped() {
// `wait-after-command` keeps the surface (and so this thread) alive
// after the child exited.
let mut f = start(sh("true"), |_| {});
let (code, _, _) = f.wait_exit();
assert_eq!(code, 0);
f.write(b"data nobody reads");
f.send(Message::Focused(true));
f.send(Message::Resize(size(90, 25)));
f.wait_for("resize after exit", |f| {
(f.state.lock_terminal().cols == 90).then_some(())
});
f.stop();
}
#[test]
fn change_config_message_updates_palette_and_colors() {
let mut f = start(sh("sleep 3"), |_| {});
let mut cfg = DerivedConfig::new(&base_config());
cfg.background = ghostty_config::types_a::Color { r: 1, g: 2, b: 3 };
cfg.enquiry_response = "x".into();
f.send(Message::ChangeConfig(Box::new(cfg)));
f.wait_for("background change", |f| {
let t = f.state.lock_terminal();
(t.colors.background.default == Some(ghostty_vt::color::Rgb { r: 1, g: 2, b: 3 }))
.then_some(())
});
assert!(f.state.lock_terminal().flags.dirty.palette);
f.stop();
}
#[test]
fn process_output_manual_api() {
// Termio can also be fed manually (no child involved).
let config = base_config();
let mut ecfg = exec::Config::new(
ghostty_os::EnvMap::new(),
ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"),
);
ecfg.command = Some(sh("true"));
let (tx, _rx) = unbounded();
let termio = Termio::init(Options {
size: size(80, 30),
full_config: &config,
config: DerivedConfig::new(&config),
backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(),
mailbox: Mailbox::new().unwrap(),
renderer_wakeup: Wakeup::new().unwrap(),
renderer_mailbox: Arc::new(RendererRec::default()),
surface_mailbox: Arc::new(SurfaceRec(tx)),
})
.unwrap();
termio.process_output(b"manual\x1b[31m!");
assert_eq!(
"manual!",
termio
.renderer_state()
.lock_terminal()
.plain_string()
.unwrap()
);
termio.process_output(b"\x1b[5n");
let m = termio.mailbox().pop().unwrap();
assert!(matches!(m, Message::Write(w) if w.slice() == b"\x1b[0n"));
}
// `Kqueue` is not a candidate on Linux, so the reactor uses `poll(2)`.
suite!(poll, ghostty_os::xev::Backend::Kqueue);
#[cfg(target_os = "linux")]
suite!(epoll, ghostty_os::xev::Backend::Epoll);
#[cfg(target_os = "linux")]
suite!(io_uring, ghostty_os::xev::Backend::IoUring);
@@ -0,0 +1,680 @@
use std::{
sync::{
Arc,
Mutex,
atomic::{AtomicUsize, Ordering},
},
thread::JoinHandle,
time::{Duration, Instant},
};
use crossbeam_channel::{Receiver, Sender, unbounded};
use ghostty_apprt::surface::Message as SurfaceMessage;
use ghostty_config::{
command::Command as ConfigCommand,
config::Config,
io::ReadableIO,
types_b::{LinuxCgroup, ShellIntegration},
};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::{
message::Message as RendererMessage,
size::{CellSize, Padding, ScreenSize, Size},
};
use ghostty_termio::{
Mailbox,
Options,
RendererMailbox,
SharedState,
SharedStateExt,
SurfaceMailbox,
Termio,
Thread,
Wakeup,
backend::{self, Backend},
exec,
message::Message,
termio::DerivedConfig,
};
struct SurfaceRec(Sender<SurfaceMessage>);
impl SurfaceMailbox for SurfaceRec {
fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize {
let _ = self.0.send(msg);
1
}
}
#[derive(Default)]
struct RendererRec {
resizes: Mutex<Vec<Size>>,
reset_blinks: AtomicUsize,
}
impl RendererMailbox for RendererRec {
fn push(&self, msg: RendererMessage, _t: Timeout) -> usize {
match msg {
RendererMessage::Resize(s) => self.resizes.lock().unwrap().push(s),
RendererMessage::ResetCursorBlink => {
self.reset_blinks.fetch_add(1, Ordering::SeqCst);
}
_ => {}
}
1
}
}
fn size(cols: u32, rows: u32) -> Size {
Size {
screen: ScreenSize {
width: cols * 10,
height: rows * 20,
},
cell: CellSize {
width: 10,
height: 20,
},
padding: Padding::default(),
}
}
struct Fixture {
state: SharedState,
mailbox: Mailbox,
stop: Wakeup,
thread: Option<JoinHandle<()>>,
surface: Receiver<SurfaceMessage>,
renderer: Arc<RendererRec>,
renderer_wakeup: Wakeup,
}
fn base_config() -> Config {
Config::default()
}
fn start(command: ConfigCommand, tweak: impl FnOnce(&mut Config)) -> Fixture {
let mut config = base_config();
tweak(&mut config);
let mut ecfg = exec::Config::new(
ghostty_os::env::get_env_map(),
ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"),
);
ecfg.command = Some(command);
ecfg.shell_integration = ShellIntegration::None;
// The default config has `linux-cgroup = single-instance` +
// `gtk-single-instance = detect` which only makes sense after the apprt
// resolved it.
ecfg.rt_pre_exec_info.linux_cgroup = LinuxCgroup::Never;
ecfg.rt_post_fork_info.linux_cgroup = LinuxCgroup::Never;
let mailbox = Mailbox::new().unwrap();
let (tx, surface) = unbounded();
let renderer = Arc::new(RendererRec::default());
let renderer_wakeup = Wakeup::new().unwrap();
let termio = Termio::init(Options {
size: size(80, 30),
full_config: &config,
config: DerivedConfig::new(&config),
backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(),
mailbox: mailbox.clone(),
renderer_wakeup: renderer_wakeup.clone(),
renderer_mailbox: renderer.clone(),
surface_mailbox: Arc::new(SurfaceRec(tx)),
})
.unwrap();
let state = Arc::clone(termio.renderer_state());
let thread = Thread::with_backend(UNDER_TEST).unwrap();
let stop = thread.stop_handle();
let handle = std::thread::Builder::new()
.name("io".into())
.spawn(move || thread.thread_main(termio))
.unwrap();
Fixture {
state,
mailbox,
stop,
thread: Some(handle),
surface,
renderer,
renderer_wakeup,
}
}
fn sh(script: &str) -> ConfigCommand {
ConfigCommand::Direct(vec!["/bin/sh".into(), "-c".into(), script.into()])
}
impl Fixture {
fn text(&self) -> String {
self.state.lock_terminal().plain_string().unwrap()
}
fn send(&self, msg: Message) {
self.mailbox.send(msg);
self.mailbox.notify();
}
fn write(&self, data: &[u8]) {
self.send(Message::write_req(data));
}
/// Wait until `f` returns Some or time out (panics).
fn wait_for<T>(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option<T>) -> T {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(v) = f(self) {
return v;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {what}; terminal text = {:?}",
self.text()
);
std::thread::sleep(Duration::from_millis(10));
}
}
fn wait_text(&self, needle: &str) {
self.wait_for(&format!("text {needle:?}"), |f| {
f.text().contains(needle).then_some(())
});
}
/// Wait for the ChildExited surface message, returning all messages
/// received up to and including it.
fn wait_exit(&self) -> (u32, u64, Vec<SurfaceMessage>) {
let deadline = Instant::now() + Duration::from_secs(10);
let mut seen = Vec::new();
loop {
let left = deadline.saturating_duration_since(Instant::now());
let msg = self
.surface
.recv_timeout(left)
.unwrap_or_else(|_| panic!("timed out waiting for child exit; seen={seen:?}"));
if let SurfaceMessage::ChildExited(c) = msg {
return (c.exit_code, c.runtime_ms, seen);
}
seen.push(msg);
}
}
fn stop(&mut self) {
self.stop.notify().unwrap();
if let Some(t) = self.thread.take() {
t.join().unwrap();
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = self.stop.notify();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
#[test]
fn printf_output_reaches_terminal_and_exit_is_reported() {
if !backend_available() {
return;
}
let mut f = start(sh("printf 'hello\\033[31mred'"), |_| {});
let (code, runtime_ms, _) = f.wait_exit();
assert_eq!(code, 0);
assert!(runtime_ms < 10_000);
// The reader thread may still be draining the pty after the exit event.
f.wait_text("hellored");
assert_eq!("hellored", f.text());
// The SGR was applied: the cursor pen is red (palette 1).
{
let t = f.state.lock_terminal();
let style = t.screen().cursor.style;
assert!(
!matches!(style.fg_color, ghostty_terminal::style::Color::None),
"pen fg should be set after \\e[31m: {style:?}"
);
assert_eq!(t.screen().cursor.x, 8);
}
// The renderer was woken up and told to reset the cursor blink.
assert!(f.renderer.reset_blinks.load(Ordering::SeqCst) >= 1);
assert!(f.renderer_wakeup_pending());
f.stop();
}
impl Fixture {
fn renderer_wakeup_pending(&self) -> bool {
self.renderer_wakeup.drain()
}
}
#[test]
fn nonzero_exit_code_is_reported() {
if !backend_available() {
return;
}
let mut f = start(sh("exit 3"), |_| {});
let (code, _, _) = f.wait_exit();
assert_eq!(code, 3);
f.stop();
}
#[test]
fn killed_by_signal_reports_signal_number() {
if !backend_available() {
return;
}
let mut f = start(sh("kill -KILL $$"), |_| {});
let (code, _, _) = f.wait_exit();
assert_eq!(code, 9);
f.stop();
}
#[test]
fn exec_failure_exits_quickly_with_error() {
if !backend_available() {
return;
}
// Abnormal exit detection itself lives in the Surface (it compares
// `runtime_ms` against `abnormal-command-exit-runtime`); termio must
// report a tiny runtime and the exit code.
let mut f = start(
ConfigCommand::Direct(vec!["/nonexistent/definitely-not-here".into()]),
|_| {},
);
let (code, runtime_ms, _) = f.wait_exit();
assert_eq!(code, 1);
assert!(runtime_ms < 5_000, "runtime {runtime_ms}");
f.stop();
}
#[test]
fn writes_reach_the_child_and_are_echoed() {
if !backend_available() {
return;
}
let mut f = start(sh("cat"), |_| {});
f.write(b"abc\n");
// Once by the tty echo and once by cat.
f.wait_for("two echoes", |f| {
(f.text().matches("abc").count() >= 2).then_some(())
});
f.stop();
}
#[test]
fn large_write_with_backpressure_is_delivered_in_order() {
if !backend_available() {
return;
}
// 256 KiB through a raw-mode `cat`: exercises the non-blocking write
// queue (the pty buffer is far smaller) and the output reader at the
// same time (cat would otherwise block on a full pty).
let mut f = start(
sh("stty raw -echo; echo ready; head -c 262144 | wc -c; echo done"),
|_| {},
);
// Writing before `stty raw` applies would go through the cooked-mode
// line discipline, which silently drops bytes past MAX_CANON (4095)
// on a line without a newline.
f.wait_text("ready");
let chunk = vec![b'x'; 4096];
for _ in 0..64 {
f.write(&chunk);
}
f.wait_text("262144");
f.wait_text("done");
f.stop();
}
#[test]
fn linefeed_mode_translates_cr() {
if !backend_available() {
return;
}
let mut f = start(sh("stty raw -echo; cat -v"), |_| {});
// Give stty a moment so the first byte is not eaten by cooked mode.
std::thread::sleep(Duration::from_millis(300));
f.send(Message::LinefeedMode(true));
f.write(b"a\r");
f.wait_text("a^M");
// cat -v got "a\r\n", so it printed a newline after the ^M.
f.wait_for("cursor on second row", |f| {
(f.state.lock_terminal().screen().cursor.y == 1).then_some(())
});
f.stop();
}
#[test]
fn terminal_query_reply_is_written_back_to_the_pty() {
if !backend_available() {
return;
}
// DA1 through the whole stack: child -> pty -> reader thread -> stream
// handler -> termio mailbox -> IO thread -> pty -> child.
let mut f = start(
sh("stty raw -echo; printf '\\033[c'; head -c 12 | cat -v; echo"),
|_| {},
);
f.wait_text("^[[?62;22;52c");
f.stop();
}
#[test]
fn initial_input_is_written_after_start() {
if !backend_available() {
return;
}
let mut f = start(sh("cat"), |c| {
c.input.list.push(ReadableIO::Raw("hi there\\n".into()));
});
f.wait_for("echo of input", |f| {
(f.text().matches("hi there").count() >= 2).then_some(())
});
f.stop();
}
#[test]
fn missing_input_file_shows_error_and_thread_keeps_draining() {
if !backend_available() {
return;
}
let mut f = start(sh("true"), |c| {
c.input.list.push(ReadableIO::Path(
ghostty_os::osbytes::EncodedOs::from_plain_text("/nonexistent/ghostty-input"),
));
});
f.wait_text("A configured `input` path was not found");
assert!(
!f.state
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::CursorVisible)
);
// Messages are swallowed in drain mode and stop still works.
f.write(b"ignored");
std::thread::sleep(Duration::from_millis(50));
f.stop();
}
#[test]
fn resize_messages_are_coalesced_and_applied_to_the_pty() {
if !backend_available() {
return;
}
let mut f = start(sh("sleep 0.7; stty size"), |_| {});
std::thread::sleep(Duration::from_millis(100));
for i in 0..20u32 {
f.send(Message::Resize(size(60 + i % 5, 20 + i)));
}
f.send(Message::Resize(size(100, 40)));
f.wait_text("40 100");
{
let t = f.state.lock_terminal();
assert_eq!((t.cols, t.rows), (100, 40));
assert_eq!(t.width_px, 1000);
assert_eq!(t.height_px, 800);
}
let n = f.renderer.resizes.lock().unwrap().len();
assert!((1..21).contains(&n), "expected coalescing, got {n} resizes");
assert_eq!(
*f.renderer.resizes.lock().unwrap().last().unwrap(),
size(100, 40)
);
f.stop();
}
#[test]
fn synchronized_output_is_force_reset_by_timer() {
if !backend_available() {
return;
}
let mut f = start(sh("printf '\\033[?2026h'; sleep 5"), |_| {});
f.wait_for("sync output on", |f| {
f.state
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::SynchronizedOutput)
.then_some(())
});
let started = Instant::now();
f.wait_for("sync output reset", |f| {
(!f.state
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::SynchronizedOutput))
.then_some(())
});
let waited = started.elapsed();
assert!(
waited < Duration::from_millis(2500),
"reset took {waited:?}"
);
f.stop();
}
#[test]
fn password_input_is_detected_via_termios_polling() {
if !backend_available() {
return;
}
let mut f = start(sh("stty -echo; sleep 3"), |_| {});
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let left = deadline.saturating_duration_since(Instant::now());
match f.surface.recv_timeout(left) {
Ok(SurfaceMessage::PasswordInput(true)) => break,
Ok(_) => {}
Err(err) => panic!("never saw PasswordInput(true): {err}"),
}
}
f.stop();
}
#[test]
fn selection_scroll_timer_ticks_until_stopped() {
if !backend_available() {
return;
}
let mut f = start(sh("sleep 3"), |_| {});
f.send(Message::SelectionScroll(true));
let mut ticks = 0;
let deadline = Instant::now() + Duration::from_secs(5);
while ticks < 3 {
let left = deadline.saturating_duration_since(Instant::now());
if let Ok(SurfaceMessage::SelectionScrollTick(true)) = f.surface.recv_timeout(left) {
ticks += 1;
}
assert!(Instant::now() < deadline);
}
f.send(Message::SelectionScroll(false));
let mut saw_false = false;
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
if let Ok(SurfaceMessage::SelectionScrollTick(false)) =
f.surface.recv_timeout(Duration::from_millis(100))
{
saw_false = true;
break;
}
}
assert!(saw_false);
// And then it stops ticking.
std::thread::sleep(Duration::from_millis(100));
while f.surface.try_recv().is_ok() {}
std::thread::sleep(Duration::from_millis(100));
assert!(f.surface.try_recv().is_err());
f.stop();
}
#[test]
fn focus_events_are_sent_when_enabled() {
if !backend_available() {
return;
}
let mut f = start(
sh("stty raw -echo; printf '\\033[?1004h'; head -c 3 | cat -v; echo"),
|_| {},
);
f.wait_for("focus mode", |f| {
f.state
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::FocusEvent)
.then_some(())
});
f.send(Message::Focused(true));
f.wait_text("^[[I");
f.stop();
}
#[test]
fn size_report_mode_2048_and_xtwinops() {
if !backend_available() {
return;
}
let mut f = start(
sh("stty raw -echo; printf '\\033[18t'; head -c 10 | cat -v; echo"),
|_| {},
);
// CSI 18 t -> ESC [ 8 ; 30 ; 80 t
f.wait_text("^[[8;30;80t");
f.stop();
}
#[test]
fn clear_screen_message_at_prompt_clears_and_sends_formfeed() {
if !backend_available() {
return;
}
let mut f = start(
sh("stty raw -echo; printf 'one\\ntwo\\n'; head -c 1 | cat -v; echo"),
|_| {},
);
f.wait_text("two");
f.send(Message::ClearScreen { history: false });
// Not at a prompt (no shell integration marks): rows above the cursor
// are erased and no form feed is sent.
f.wait_for("rows above cursor erased", |f| {
let t = f.text();
(!t.contains("one")).then_some(())
});
f.stop();
}
#[test]
fn stop_kills_the_child_process_group() {
if !backend_available() {
return;
}
let dir = std::env::temp_dir().join(format!("ghostty-termio-pid-{}-{UNDER_TEST}", std::process::id()));
let pidfile = dir.display().to_string();
let mut f = start(sh(&format!("echo $$ > {pidfile}; exec sleep 60")), |_| {});
let pid: i32 = f.wait_for("pid file", |_| {
std::fs::read_to_string(&dir)
.ok()
.and_then(|s| s.trim().parse().ok())
});
let started = Instant::now();
f.stop();
assert!(started.elapsed() < Duration::from_secs(5));
let alive = rustix::process::test_kill_process(rustix::process::Pid::from_raw(pid).unwrap());
assert!(alive.is_err(), "child {pid} is still alive");
let _ = std::fs::remove_file(&dir);
}
#[test]
fn writes_after_exit_are_ignored_and_thread_stays_alive_until_stopped() {
if !backend_available() {
return;
}
// `wait-after-command` keeps the surface (and so this thread) alive
// after the child exited.
let mut f = start(sh("true"), |_| {});
let (code, _, _) = f.wait_exit();
assert_eq!(code, 0);
f.write(b"data nobody reads");
f.send(Message::Focused(true));
f.send(Message::Resize(size(90, 25)));
f.wait_for("resize after exit", |f| {
(f.state.lock_terminal().cols == 90).then_some(())
});
f.stop();
}
#[test]
fn change_config_message_updates_palette_and_colors() {
if !backend_available() {
return;
}
let mut f = start(sh("sleep 3"), |_| {});
let mut cfg = DerivedConfig::new(&base_config());
cfg.background = ghostty_config::types_a::Color { r: 1, g: 2, b: 3 };
cfg.enquiry_response = "x".into();
f.send(Message::ChangeConfig(Box::new(cfg)));
f.wait_for("background change", |f| {
let t = f.state.lock_terminal();
(t.colors.background.default == Some(ghostty_vt::color::Rgb { r: 1, g: 2, b: 3 }))
.then_some(())
});
assert!(f.state.lock_terminal().flags.dirty.palette);
f.stop();
}
#[test]
fn process_output_manual_api() {
if !backend_available() {
return;
}
// Termio can also be fed manually (no child involved).
let config = base_config();
let mut ecfg = exec::Config::new(
ghostty_os::EnvMap::new(),
ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"),
);
ecfg.command = Some(sh("true"));
let (tx, _rx) = unbounded();
let termio = Termio::init(Options {
size: size(80, 30),
full_config: &config,
config: DerivedConfig::new(&config),
backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(),
mailbox: Mailbox::new().unwrap(),
renderer_wakeup: Wakeup::new().unwrap(),
renderer_mailbox: Arc::new(RendererRec::default()),
surface_mailbox: Arc::new(SurfaceRec(tx)),
})
.unwrap();
termio.process_output(b"manual\x1b[31m!");
assert_eq!(
"manual!",
termio
.renderer_state()
.lock_terminal()
.plain_string()
.unwrap()
);
termio.process_output(b"\x1b[5n");
let m = termio.mailbox().pop().unwrap();
assert!(matches!(m, Message::Write(w) if w.slice() == b"\x1b[0n"));
}
/// Skip (and say so) when the event loop backend is unavailable here, e.g.
/// io_uring denied by the kernel or sandbox.
fn backend_available() -> bool {
// (`Kqueue` stands for the portable poll(2) loop here.)
let ok = UNDER_TEST != ghostty_os::xev::Backend::IoUring
|| ghostty_os::xev::available(UNDER_TEST);
if !ok {
eprintln!("SKIP: event loop backend {UNDER_TEST} is unavailable on this system");
}
ok
}