Files
ghostty-rs/crates/ghostty-termio/src/thread.rs
T
ddidderr f9094c8152 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)
2026-10-12 14:49:00 +02:00

503 lines
17 KiB
Rust

//! Represents the "writer" thread for terminal IO. The reader side is
//! handled by the Termio struct itself and dependent on the underlying
//! implementation (i.e. if its a pty, manual, etc.) (`termio/Thread.zig`).
//!
//! The writer thread does handle writing bytes to the pty but also handles
//! different events such as starting synchronized output, changing some
//! modes (like linefeed), etc. The goal is to offload as much from the
//! reader thread as possible since it is the hot path in parsing VT
//! sequences and updating terminal state.
//!
//! This thread state can only be used by one thread at a time.
//!
//! # Event loop
//!
//! 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,
//! termios polling). See [`Thread::thread_main`].
use std::{
io,
time::{Duration, Instant},
};
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 crate::reactor::{Backend, Interest, Reactor};
use crate::{
backend::ThreadData as BackendThreadData,
message::Message,
state::SharedStateExt,
termio::{Error, Termio, ThreadData},
wakeup::Wakeup,
};
/// The Windows event loop (`WaitForMultipleObjects`).
#[cfg(windows)]
#[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;
/// The number of milliseconds before we reset the synchronized output flag
/// if the running program hasn't already.
pub const SYNC_RESET_MS: u64 = 1000;
/// The number of milliseconds between each movement during selection scrolling.
pub const SELECTION_SCROLL_MS: u64 = 15;
#[derive(Debug, Default)]
struct Flags {
/// This is set to true only when an abnormal exit is detected. It
/// tells our mailbox system to drain and ignore all messages.
drain: bool,
/// True if linefeed mode is enabled. This is duplicated here so that the
/// write thread doesn't need to grab a lock to check this on every write.
linefeed_mode: bool,
/// This is true when the inspector is active.
has_inspector: bool,
}
/// See the module docs.
pub struct Thread {
/// This can be used to stop the thread on the next loop iteration.
stop: Wakeup,
/// This is used for timer-based selection scrolling.
scroll_active: bool,
scroll_deadline: Option<Instant>,
/// This is used to coalesce resize events.
coalesce_deadline: Option<Instant>,
coalesce_resize: Option<Size>,
/// This timer is used to reset synchronized output modes so that
/// the terminal doesn't freeze with a bad actor.
sync_reset_deadline: Option<Instant>,
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>>,
}
impl Thread {
/// Initialize the thread. This does not START the thread. This only sets
/// 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,
coalesce_deadline: None,
coalesce_resize: None,
sync_reset_deadline: None,
flags: Flags::default(),
crash_dims: None,
})
}
/// A handle that, when notified, stops the thread on the next loop
/// iteration (the Zig `stop` async).
pub fn stop_handle(&self) -> Wakeup {
self.stop.clone()
}
/// The main entrypoint for the thread. Consumes the [`Termio`]. Returns
/// once the stop handle was notified; dropping the termio afterwards
/// cleans everything up.
pub fn thread_main(mut self, mut io: Termio) {
// Setup our crash metadata
let _crash =
ghostty_crash::ThreadGuard::new(ghostty_crash::ThreadType::Io, self.crash_dims.clone());
// Call child function so we can use errors...
let result = self.thread_main_(&mut io);
if let Err(err) = result {
log::warn!("error in io thread err={err}");
report_start_error(&io, &err);
// If our loop is not stopped, then we need to keep running so that
// messages are drained and we can wait for the surface to send a stop
// message.
log::warn!("abrupt io thread exit detected, draining mailbox until stopped");
self.flags.drain = true;
self.drain_until_stop(&io);
log::debug!("io thread fully exiting after abnormal failure");
}
}
fn thread_main_(&mut self, io: &mut Termio) -> Result<(), Error> {
// Run our thread start/end callbacks. This allows the implementation
// to hook into the event loop as needed.
let mut data = io.thread_enter()?;
log::debug!("starting IO thread");
self.run(io, &mut data);
log::debug!("starting IO thread shutdown");
io.thread_exit(&mut data);
log::debug!("IO thread exited");
Ok(())
}
/// After an abnormal failure: discard messages until the stop signal.
#[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 interests = [
Interest {
token: TOKEN_MAILBOX,
fd: mailbox.wakeup().as_fd(),
write: false,
},
Interest {
token: TOKEN_STOP,
fd: self.stop.as_fd(),
write: false,
},
];
if let Err(err) = reactor.wait(&interests, None, &mut ready) {
log::error!("failed to poll for draining err={err}");
break;
}
if ready.contains(&TOKEN_MAILBOX) {
mailbox.wakeup().drain();
while mailbox.pop().is_some() {}
}
if ready.contains(&TOKEN_STOP) {
self.stop.drain();
break;
}
}
self.reactor = Some(reactor);
}
fn next_deadline(&self, td: &ThreadData) -> Option<Instant> {
[
self.scroll_deadline,
self.coalesce_deadline,
self.sync_reset_deadline,
match &td.backend {
BackendThreadData::Exec(e) => e.next_deadline(),
},
]
.into_iter()
.flatten()
.min()
}
/// 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| d.saturating_duration_since(Instant::now()));
// 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();
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,
});
}
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.contains(&TOKEN_STOP) {
self.stop.drain();
return;
}
// The mailbox has messages.
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 ready.contains(&TOKEN_PROCESS) {
td.backend.as_exec_mut().on_process_ready(None);
}
if ready.contains(&TOKEN_FLATPAK) {
td.backend.as_exec_mut().on_flatpak_ready();
}
if ready.contains(&TOKEN_PTY_OUT) {
td.backend.as_exec_mut().flush_writes();
}
self.run_timers(io, td, Instant::now());
}
}
fn run_timers(&mut self, io: &mut Termio, td: &mut ThreadData, now: Instant) {
td.backend.as_exec_mut().on_timer(now);
if self.sync_reset_deadline.is_some_and(|d| now >= d) {
self.sync_reset_deadline = None;
io.reset_synchronized_output();
}
if self.coalesce_deadline.is_some_and(|d| now >= d) {
self.coalesce_deadline = None;
if let Some(v) = self.coalesce_resize.take()
&& let Err(err) = io.resize(td, v)
{
log::warn!("error during resize err={err}");
}
}
if self.scroll_deadline.is_some_and(|d| now >= d) {
self.scroll_deadline = None;
self.selection_scroll_tick(td, now);
}
}
/// Drain the mailbox, handling all the messages in our terminal
/// implementation.
fn drain_mailbox(&mut self, io: &mut Termio, td: &mut ThreadData) {
// If we're draining, we just drain the mailbox and return.
if self.flags.drain {
while io.mailbox().pop().is_some() {}
return;
}
let mut redraw = false;
while let Some(message) = io.mailbox().pop() {
// If we have a message we always redraw
redraw = true;
match message {
Message::ColorSchemeReport { force } => io.color_scheme_report(td, force),
Message::Crash => panic!("crash request, crashing intentionally"),
Message::ChangeConfig(config) => io.change_config(td, *config),
Message::Inspector(v) => self.flags.has_inspector = v,
Message::Resize(v) => self.handle_resize(v),
Message::SizeReport(v) => io.size_report(td, v),
Message::ClearScreen { history } => io.clear_screen(td, history),
Message::ScrollViewport(v) => io.scroll_viewport(v),
Message::SelectionScroll(v) => {
if v {
self.start_scroll_timer();
} else {
self.stop_scroll_timer();
}
}
Message::JumpToPrompt(v) => io.jump_to_prompt(v),
Message::StartSynchronizedOutput => self.start_synchronized_output(),
Message::LinefeedMode(v) => self.flags.linefeed_mode = v,
Message::Focused(v) => io.focus_gained(td, v),
Message::Write(w) => io.queue_write(td, w.slice(), self.flags.linefeed_mode),
}
}
// Trigger a redraw after we've drained so we don't waste cyces
// messaging a redraw.
if redraw && let Err(err) = io.renderer_wakeup().notify() {
log::warn!("failed to notify renderer err={err}");
}
}
fn start_synchronized_output(&mut self) {
// (Re)start the timer: xev's `Timer.reset` cancels a running timer.
self.sync_reset_deadline = Some(Instant::now() + Duration::from_millis(SYNC_RESET_MS));
}
fn handle_resize(&mut self, resize: Size) {
self.coalesce_resize = Some(resize);
// If the timer is already active we just return. In the future we want
// to reset the timer up to a maximum wait time but for now this ensures
// relatively smooth resizing.
if self.coalesce_deadline.is_some() {
return;
}
self.coalesce_deadline = Some(Instant::now() + Duration::from_millis(COALESCE_MIN_MS));
}
fn start_scroll_timer(&mut self) {
self.scroll_active = true;
// If it is already active, e.g. start_scroll_timer is called multiple
// times, then we just return. We can't simply check `scroll_active`
// because its possible that `stop_scroll_timer` was called but there
// was no loop tick between then and now to halt out completion.
if self.scroll_deadline.is_some() {
return;
}
self.scroll_deadline = Some(Instant::now() + Duration::from_millis(SELECTION_SCROLL_MS));
}
fn stop_scroll_timer(&mut self) {
// This will stop the scrolling on the next iteration.
self.scroll_active = false;
}
fn selection_scroll_tick(&mut self, td: &ThreadData, now: Instant) {
// Send the tick to the main surface
td.surface_mailbox.push(
SurfaceMessage::SelectionScrollTick(self.scroll_active),
Timeout::Instant,
);
if self.scroll_active {
self.scroll_deadline = Some(now + Duration::from_millis(SELECTION_SCROLL_MS));
}
}
}
/// If there is an error starting the IO thread, we replace our terminal
/// screen with the error message. It might be better in the future to send
/// the error to the surface thread and let the apprt deal with it
/// in some way but this works for now. Without this, the user would
/// just see a blank terminal window.
fn report_start_error(io: &Termio, err: &Error) {
let mut t = io.renderer_state().lock_terminal();
// Hide the cursor
t.modes.set(Mode::CursorVisible, false);
let msg: String = match err {
#[cfg(unix)]
Error::Exec(crate::exec::Error::Pty(crate::pty::Error::OpenptyFailed)) => "\
Your system cannot allocate any more pty devices.
Ghostty requires a pty device to launch a new terminal.
This error is usually due to having too many terminal
windows open or having another program that is using too
many pty devices.
Please free up some pty devices and try again."
.to_owned(),
Error::InputNotFound | Error::InputFailed => "\
A configured `input` path was not found, was not readable,
was too large, or the underlying pty failed to accept
the write.
Ghostty can't continue since it can't guarantee that
initial terminal state will be as desired. Please review
the value of `input` in your configuration file and
ensure that all the path values exist and are readable."
.to_owned(),
err => format!(
"\
error starting IO thread: {err}
The underlying shell or command was unable to be started.
This error is usually due to exhausting a system resource.
If this looks like a bug, please report it.
This terminal is non-functional. Please close it and try again."
),
};
t.erase_display(csi::EraseDisplay::Complete, false);
let _ = t.print_string(&msg);
}