Files
ghostty-rs/crates/ghostty-termio/src/thread.rs
T
ddidderr ad76ae2301 feat(core): port App/Surface and wire up the runnable ghostty binary
This is the integration step that turns the ported crates into a working
terminal: `cargo run` now opens a GTK window running the user's shell.

ghostty-core ports App.zig, Surface.zig (split into keyboard, mouse,
binding, clipboard and config submodules), surface_mouse.zig and
global.zig, and implements the runtime traits. perform_binding_action
handles every binding action.

Cross-crate integration:
- The core<->runtime traits (CoreApp, CoreSurface, RtApp, RtSurface,
  Waker) move from apprt-gtk into ghostty_apprt::runtime_api so the core
  stays runtime-agnostic; Scrollbar/RendererHealth stand-ins are replaced
  by the real terminal/renderer types.
- termio now uses ghostty_renderer::state::{State, SharedState}; the
  renderer calls kitty_update; image-storage-limit is applied at init
  and on config change.
- The search thread locks the terminal through a TerminalLock trait so
  it can share the render state mutex; termio's Wakeup gained a hook to
  wake the renderer thread.
- apprt-gtk: GL proc addresses are resolved through libepoxy's
  epoxy_glFoo pointer variables (plain glFoo symbols resolved to null),
  default DPI fixed to 96, libadwaita version read without GTK init so
  --version works headless.

The root binary dispatches +actions to ghostty-cli (list-fonts,
show-face and new-window are now wired) and otherwise runs the GTK app
with the real core.

Verified at runtime on Wayland and X11: shell prompt and colored output
render, child exit closes the window cleanly. Not yet verified: keyboard
and mouse input, clipboard, IME, tabs and splits (no key injection was
possible in this environment). GHOSTTY_DEBUG_SCREENSHOT=<png> dumps the
renderer's last frame for headless checks.

Test Plan:
- cargo test --workspace (2890 passed)
- cargo clippy --workspace --all-targets -- -D warnings
- GSETTINGS_BACKEND=memory GHOSTTY_RESOURCES_DIR=/usr/share/ghostty \
  cargo run
2026-10-12 01:33:44 +02:00

423 lines
15 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 `poll(2)` loop 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;
use std::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;
use ghostty_vt::modes::Mode;
use rustix::event::{PollFd, PollFlags};
use crate::backend::ThreadData as BackendThreadData;
use crate::exec;
use crate::message::Message;
use crate::state::SharedStateExt;
use crate::termio::{Error, Termio, ThreadData};
use crate::wakeup::Wakeup;
/// 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,
}
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> {
Ok(Thread {
stop: Wakeup::new()?,
scroll_active: false,
scroll_deadline: None,
coalesce_deadline: None,
coalesce_resize: None,
sync_reset_deadline: None,
flags: Flags::default(),
})
}
/// 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) {
// 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.
fn drain_until_stop(&mut self, io: &Termio) {
let mailbox = io.mailbox().clone();
loop {
let mut fds = [
PollFd::from_borrowed_fd(mailbox.wakeup().as_fd(), PollFlags::IN),
PollFd::from_borrowed_fd(self.stop.as_fd(), PollFlags::IN),
];
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;
}
}
let stop = fds[1].revents().contains(PollFlags::IN);
let msgs = fds[0].revents().contains(PollFlags::IN);
if msgs {
mailbox.wakeup().drain();
while mailbox.pop().is_some() {}
}
if stop {
self.stop.drain();
return;
}
}
}
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.
fn run(&mut self, io: &mut Termio, td: &mut ThreadData) {
let mailbox = io.mailbox().clone();
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 _,
}
});
// 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));
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;
}
}
let rev: Vec<PollFlags> = fds.iter().map(PollFd::revents).collect();
drop(fds);
let ready = |i: usize| !rev[i].is_empty();
// Stop requested.
if ready(1) {
self.stop.drain();
return;
}
// The mailbox has messages.
if ready(0) {
// 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) {
td.backend.as_exec_mut().on_process_ready(None);
}
if fp_idx.is_some_and(ready) {
td.backend.as_exec_mut().on_flatpak_ready();
}
if out_idx.is_some_and(ready) {
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 {
Error::Exec(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);
}