From c81375e6f1c0c84c1b0d4efc3119bcfa1718f7a2 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 22:58:48 +0200 Subject: [PATCH] feat(termio): port the IO thread, Exec backend and stream handler Ports the remainder of ZIG/src/termio: Options, backend, message, mailbox, Termio, Thread, Exec and stream_handler. Threading model (replacing libxev): the IO thread owns Termio and runs a single poll(2) loop over the mailbox eventfd, a stop eventfd, the child pidfd and pty POLLOUT, driving Ghostty's timers (25 ms resize coalescing, 1 s synchronized-output, 15 ms selection scroll, 200 ms termios polling). A separate pty read thread locks the shared renderer state and feeds the terminal. Renderer and surface interaction go through RendererMailbox/SurfaceMailbox traits. The stream handler answers all queries (DA, DSR, XTVERSION, DECRQM, OSC color queries, XTGETTCAP, DECRQSS, ...) and forwards titles, pwd, OSC 52, notifications, progress and bell as surface messages. Zig unlocks the renderer mutex when a mailbox is full; here overflow goes to an ordered outbox flushed after the locks drop. IO1's local stand-ins are replaced with the real config types. Open: RendererState lives in termio for now (renderer State.zig is being ported); kitty graphics replies and tmux viewer are not hooked up; wait-after-command lives in Surface (core); Flatpak stop path needs a ghostty-os change. Test Plan: - cargo test -p ghostty-termio (102 unit + 21 end-to-end tests on a real pty, incl. backpressure, DA1 replies, resize coalescing, exits) --- Cargo.lock | 43 +- crates/ghostty-termio/Cargo.toml | 14 +- crates/ghostty-termio/src/backend.rs | 120 + crates/ghostty-termio/src/command.rs | 73 +- crates/ghostty-termio/src/exec.rs | 1330 ++++++++++ crates/ghostty-termio/src/lib.rs | 30 +- crates/ghostty-termio/src/mailbox.rs | 203 ++ crates/ghostty-termio/src/message.rs | 137 ++ crates/ghostty-termio/src/options.rs | 47 + crates/ghostty-termio/src/pty.rs | 16 +- .../ghostty-termio/src/shell_integration.rs | 131 +- crates/ghostty-termio/src/sinks.rs | 37 + crates/ghostty-termio/src/state.rs | 37 + crates/ghostty-termio/src/stream_handler.rs | 2186 +++++++++++++++++ crates/ghostty-termio/src/termio.rs | 749 ++++++ crates/ghostty-termio/src/thread.rs | 421 ++++ crates/ghostty-termio/src/wakeup.rs | 96 + .../ghostty-termio/tests/exec_integration.rs | 579 +++++ docs/rewrite/notes/ghostty-termio-IO2.md | 116 + 19 files changed, 6170 insertions(+), 195 deletions(-) create mode 100644 crates/ghostty-termio/src/backend.rs create mode 100644 crates/ghostty-termio/src/exec.rs create mode 100644 crates/ghostty-termio/src/mailbox.rs create mode 100644 crates/ghostty-termio/src/message.rs create mode 100644 crates/ghostty-termio/src/options.rs create mode 100644 crates/ghostty-termio/src/sinks.rs create mode 100644 crates/ghostty-termio/src/state.rs create mode 100644 crates/ghostty-termio/src/stream_handler.rs create mode 100644 crates/ghostty-termio/src/termio.rs create mode 100644 crates/ghostty-termio/src/thread.rs create mode 100644 crates/ghostty-termio/src/wakeup.rs create mode 100644 crates/ghostty-termio/tests/exec_integration.rs create mode 100644 docs/rewrite/notes/ghostty-termio-IO2.md diff --git a/Cargo.lock b/Cargo.lock index aa06a2c..027623e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,25 +571,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1360,6 +1341,7 @@ dependencies = [ "bytemuck", "crossbeam-channel", "ghostty-config", + "ghostty-datastruct", "ghostty-font", "ghostty-input", "ghostty-terminal", @@ -1871,9 +1853,6 @@ name = "jpeg-decoder" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" -dependencies = [ - "rayon", -] [[package]] name = "js-sys" @@ -2814,26 +2793,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/crates/ghostty-termio/Cargo.toml b/crates/ghostty-termio/Cargo.toml index 2535bc7..2ca013a 100644 --- a/crates/ghostty-termio/Cargo.toml +++ b/crates/ghostty-termio/Cargo.toml @@ -4,17 +4,19 @@ version.workspace = true edition.workspace = true license.workspace = true -# NOTE: the `ghostty-config` and `ghostty-terminal` dependencies were removed -# for now (config is being written concurrently, terminal is in progress). -# TODO(integration): re-add them when Exec/Termio/stream_handler are ported and -# replace the local stand-ins (shell_integration::{Command, ShellIntegrationFeatures}, -# command::{LinuxCgroup, GtkSingleInstance}). [dependencies] +crossbeam-channel = "0.5.17" +ghostty-apprt.workspace = true +ghostty-config.workspace = true +ghostty-datastruct.workspace = true ghostty-os.workspace = true +ghostty-renderer.workspace = true +ghostty-terminal.workspace = true +ghostty-vt.workspace = true libc = "0.2.190" log = "0.4.34" nix = { version = "0.31.3", features = ["signal"] } -rustix = { version = "1.1.5", features = ["fs", "process", "pty", "termios", "stdio"] } +rustix = { version = "1.1.5", features = ["fs", "process", "pty", "termios", "stdio", "event", "pipe"] } thiserror = "2.0.21" # Termio forks/execs children and sets up ptys; unsafe is denied by default diff --git a/crates/ghostty-termio/src/backend.rs b/crates/ghostty-termio/src/backend.rs new file mode 100644 index 0000000..79727c7 --- /dev/null +++ b/crates/ghostty-termio/src/backend.rs @@ -0,0 +1,120 @@ +//! Backend implementations (`termio/backend.zig`). A backend is responsible +//! for owning the pty behavior and providing read/write capabilities. + +use std::sync::Arc; + +use ghostty_renderer::size::{GridSize, ScreenSize}; +use ghostty_terminal::terminal::Terminal; + +use crate::exec::{self, Exec}; +use crate::sinks::SurfaceMailbox; +use crate::state::RendererState; +use crate::termio::Output; + +/// The kinds of backends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Exec, +} + +/// Configuration for the various backend types. +pub enum Config { + /// Exec uses posix exec to run a command with a pty. + Exec(exec::Config), +} + +/// Backend implementations. +pub enum Backend { + Exec(Exec), +} + +impl Backend { + pub fn new(config: Config) -> Result { + match config { + Config::Exec(cfg) => Ok(Backend::Exec(Exec::new(cfg)?)), + } + } + + pub fn kind(&self) -> Kind { + match self { + Backend::Exec(_) => Kind::Exec, + } + } + + pub fn init_terminal(&mut self, t: &mut Terminal) { + match self { + Backend::Exec(e) => e.init_terminal(t), + } + } + + pub fn thread_enter( + &mut self, + output: Arc, + surface_mailbox: Arc, + renderer_state: Arc, + ) -> Result { + match self { + Backend::Exec(e) => Ok(ThreadData::Exec(e.thread_enter( + output, + surface_mailbox, + renderer_state, + )?)), + } + } + + pub fn thread_exit(&mut self, td: &mut ThreadData) { + match (self, td) { + (Backend::Exec(e), ThreadData::Exec(td)) => e.thread_exit(td), + } + } + + pub fn focus_gained(&mut self, td: &mut ThreadData, focused: bool) { + match (self, td) { + (Backend::Exec(e), ThreadData::Exec(td)) => e.focus_gained(td, focused), + } + } + + pub fn resize( + &mut self, + grid_size: GridSize, + screen_size: ScreenSize, + ) -> Result<(), exec::Error> { + match self { + Backend::Exec(e) => e.resize(grid_size, screen_size), + } + } + + pub fn queue_write(&mut self, td: &mut ThreadData, data: &[u8], linefeed: bool) { + match (self, td) { + (Backend::Exec(e), ThreadData::Exec(td)) => e.queue_write(td, data, linefeed), + } + } +} + +/// Termio thread data. See `termio::ThreadData` for docs. +pub enum ThreadData { + Exec(exec::ThreadData), +} + +impl ThreadData { + pub fn kind(&self) -> Kind { + match self { + ThreadData::Exec(_) => Kind::Exec, + } + } + + pub fn as_exec(&self) -> &exec::ThreadData { + match self { + ThreadData::Exec(e) => e, + } + } + + pub fn as_exec_mut(&mut self) -> &mut exec::ThreadData { + match self { + ThreadData::Exec(e) => e, + } + } + + /// `ThreadData.changeConfig`: nothing to do for the exec backend. + pub fn change_config(&mut self, _config: &crate::termio::DerivedConfig) {} +} diff --git a/crates/ghostty-termio/src/command.rs b/crates/ghostty-termio/src/command.rs index d004c3b..5e0d03f 100644 --- a/crates/ghostty-termio/src/command.rs +++ b/crates/ghostty-termio/src/command.rs @@ -89,39 +89,43 @@ impl Exit { } } -/// Mirrors `config.Command`'s relevant enum `LinuxCgroup`. -/// TODO(integration): replace with the ghostty-config type. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LinuxCgroup { - Never, - Always, - #[default] - SingleInstance, -} - -/// TODO(integration): replace with the ghostty-config type. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum GtkSingleInstance { - False, - True, - #[default] - Detect, -} +pub use ghostty_config::types_b::{GtkSingleInstance, LinuxCgroup}; /// Configuration information needed by the apprt pre exec function /// (`apprt/gtk/pre_exec.zig` `PreExecInfo`). Trivially copyable. -/// TODO(integration): build from `ghostty-config::Config` (`init(cfg)`). -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy)] pub struct RtPreExecInfo { pub gtk_single_instance: GtkSingleInstance, pub linux_cgroup: LinuxCgroup, pub linux_cgroup_hard_fail: bool, } +impl Default for RtPreExecInfo { + /// Config defaults (`linux-cgroup = single-instance`, + /// `gtk-single-instance = detect`). + fn default() -> Self { + Self { + gtk_single_instance: GtkSingleInstance::Detect, + linux_cgroup: LinuxCgroup::SingleInstance, + linux_cgroup_hard_fail: false, + } + } +} + +impl RtPreExecInfo { + /// `PreExecInfo.init(cfg)`. + pub fn init(cfg: &ghostty_config::config::Config) -> Self { + Self { + gtk_single_instance: cfg.gtk_single_instance, + linux_cgroup: cfg.linux_cgroup, + linux_cgroup_hard_fail: cfg.linux_cgroup_hard_fail, + } + } +} + /// Configuration information needed by the apprt post fork function /// (`apprt/gtk/post_fork.zig` `PostForkInfo`). -/// TODO(integration): build from `ghostty-config::Config` (`init(cfg)`). -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy)] pub struct RtPostForkInfo { pub gtk_single_instance: GtkSingleInstance, pub linux_cgroup: LinuxCgroup, @@ -130,6 +134,31 @@ pub struct RtPostForkInfo { pub linux_cgroup_processes_limit: Option, } +impl Default for RtPostForkInfo { + fn default() -> Self { + Self { + gtk_single_instance: GtkSingleInstance::Detect, + linux_cgroup: LinuxCgroup::SingleInstance, + linux_cgroup_hard_fail: false, + linux_cgroup_memory_limit: None, + linux_cgroup_processes_limit: None, + } + } +} + +impl RtPostForkInfo { + /// `PostForkInfo.init(cfg)`. + pub fn init(cfg: &ghostty_config::config::Config) -> Self { + Self { + gtk_single_instance: cfg.gtk_single_instance, + linux_cgroup: cfg.linux_cgroup, + linux_cgroup_hard_fail: cfg.linux_cgroup_hard_fail, + linux_cgroup_memory_limit: cfg.linux_cgroup_memory_limit, + linux_cgroup_processes_limit: cfg.linux_cgroup_processes_limit, + } + } +} + /// A command to launch. #[derive(Default)] pub struct Command { diff --git a/crates/ghostty-termio/src/exec.rs b/crates/ghostty-termio/src/exec.rs new file mode 100644 index 0000000..a9b9252 --- /dev/null +++ b/crates/ghostty-termio/src/exec.rs @@ -0,0 +1,1330 @@ +//! Exec implements the logic for starting and stopping a subprocess with a +//! pty as well as spinning up the necessary read thread to read from the +//! pty and forward it to the Termio instance (`termio/Exec.zig`). +//! +//! # Event loop mapping +//! +//! libxev completions (process watcher, write stream, termios timer) are +//! replaced by state in [`ThreadData`] that the IO thread's `poll(2)` loop +//! (see [`crate::thread`]) consults: +//! +//! * process exit: a `pidfd` (or, if `pidfd_open` is unavailable, a 50ms +//! `waitpid(WNOHANG)` poll) is polled for readability; Flatpak host +//! commands report through a channel + wakeup instead, +//! * pty writes: a queue of byte chunks flushed with non-blocking `write`; +//! `POLLOUT` is polled only while the queue is non-empty, +//! * termios timer: a plain deadline. + +use std::collections::VecDeque; +use std::io; +use std::os::fd::{AsFd, BorrowedFd, OwnedFd}; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use crossbeam_channel::Receiver; +use ghostty_config::command::Command as ConfigCommand; +use ghostty_config::repeatable_string_map::RepeatableStringMap; +use ghostty_config::types_b::{ShellIntegration, ShellIntegrationFeatures}; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_os::file::Rlimit; +use ghostty_renderer::size::{GridSize, ScreenSize}; +use ghostty_terminal::terminal::Terminal; +use rustix::event::{PollFd, PollFlags}; +use rustix::process::{Pid, PidfdFlags, Signal, WaitOptions}; + +use crate::command::{ + self, Command, Exit, PostForkError, RtPostForkInfo, RtPreExecInfo, linux_cgroup_post_fork, + linux_cgroup_pre_exec, +}; +use crate::pty::{self, Mode, Pty, Winsize}; +use crate::shell_integration::{self, EnvMap, Shell}; +use crate::sinks::SurfaceMailbox; +use crate::state::RendererState; +use crate::termio::Output; +use crate::wakeup::Wakeup; +use ghostty_apprt::surface::{ChildExited, Message as SurfaceMessage}; + +/// The termios poll rate in milliseconds. +pub const TERMIOS_POLL_MS: u64 = 200; + +/// Poll interval of the `waitpid` fallback process watcher. +const PROCESS_POLL_MS: u64 = 50; + +/// `build_config.version_string`. +pub use crate::stream_handler::VERSION_STRING; + +/// Errors from starting and running the exec backend. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Pty(#[from] pty::Error), + #[error(transparent)] + Start(#[from] command::StartError), + #[error(transparent)] + ShellIntegration(#[from] shell_integration::Error), + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("os error: {0}")] + Errno(#[from] rustix::io::Errno), + #[error("flatpak host command failed: {0}")] + Flatpak(ghostty_os::flatpak::Error), + #[error("process not started")] + ProcessNotStarted, + #[error("process has no pid")] + ProcessNoPid, + #[error("failed to kill the process group")] + KillFailed, +} + +/// The function that moves a new child into a transient systemd scope +/// (`cgroup.createScope`): `(pid, memory_high, tasks_max)`. Returns `None` +/// if there is no D-Bus connection and `Some(Err(msg))` on failure. +/// TODO(integration): provided by ghostty-apprt-gtk (it owns the D-Bus +/// connection). +pub type CreateScopeFn = + Arc, Option) -> Option> + Send + Sync>; + +/// Configuration for the exec backend (`Exec.Config`). +pub struct Config { + pub command: Option, + pub env: EnvMap, + pub env_override: RepeatableStringMap, + pub shell_integration: ShellIntegration, + pub shell_integration_features: ShellIntegrationFeatures, + pub cursor_blink: Option, + pub working_directory: Option, + pub resources_dir: Option, + pub term: String, + + pub rt_pre_exec_info: RtPreExecInfo, + pub rt_post_fork_info: RtPostForkInfo, + + /// See [`CreateScopeFn`]. Only used if `rt_post_fork_info` asks for a + /// cgroup transition. + pub create_scope: Option, + + /// The rlimit to restore in the child (Zig: `global_state.rlimits`). + pub restore_rlimit: Option, +} + +impl Config { + /// A config with Zig-struct-style defaults for everything optional. + pub fn new(env: EnvMap, term: impl Into) -> Config { + Config { + command: None, + env, + env_override: RepeatableStringMap::default(), + shell_integration: ShellIntegration::Detect, + shell_integration_features: ShellIntegrationFeatures::default(), + cursor_blink: None, + working_directory: None, + resources_dir: None, + term: term.into(), + rt_pre_exec_info: RtPreExecInfo::default(), + rt_post_fork_info: RtPostForkInfo::default(), + create_scope: None, + restore_rlimit: None, + } + } +} + +/// The exec backend. Owns the (not yet started) subprocess state. +pub struct Exec { + subprocess: Subprocess, +} + +impl Exec { + /// Initialize the exec state. This will NOT start it, this only sets + /// up the internal state necessary to start it later. + pub fn new(cfg: Config) -> Result { + Ok(Exec { + subprocess: Subprocess::new(cfg)?, + }) + } + + /// The working directory the subprocess will be started in, if any. + pub fn cwd(&self) -> Option<&str> { + self.subprocess.cwd.as_deref() + } + + /// The argv that will be executed. + pub fn args(&self) -> &[String] { + &self.subprocess.args + } + + /// The environment the subprocess will get (before it is started). + pub fn env(&self) -> Option<&EnvMap> { + self.subprocess.env.as_ref() + } + + /// Call to initialize the terminal state as necessary for this backend. + /// This is called before any termio begins. This should not be called + /// after termio begins because it may put the internal terminal state + /// into a bad state. + pub fn init_terminal(&mut self, term: &mut Terminal) { + // If we have an initial pwd requested by the subprocess, then we + // set that on the terminal now. This allows rapidly initializing + // new surfaces to use the proper pwd. + if let Some(cwd) = &self.subprocess.cwd + && let Err(err) = term.set_pwd(cwd.as_bytes()) + { + log::warn!("error setting initial pwd err={err:?}"); + } + + // Setup our initial grid/screen size from the terminal. This + // can't fail because the pty should not exist at this point. + let _ = self.resize( + GridSize { + columns: term.cols, + rows: term.rows, + }, + ScreenSize { + width: term.width_px, + height: term.height_px, + }, + ); + } + + /// Start the subprocess and the read thread. `output` is where the read + /// thread delivers pty output. + pub fn thread_enter( + &mut self, + output: Arc, + surface_mailbox: Arc, + renderer_state: Arc, + ) -> Result { + // Start our subprocess + let (read_fd, write_fd) = self.subprocess.start()?; + + // Watcher to detect subprocess exit + let (watch, flatpak_events) = match self.subprocess.process.as_mut() { + Some(Process::ForkExec(cmd)) => { + let pid = cmd.pid.ok_or(Error::ProcessNoPid)?; + (ProcessWatch::for_pid(pid), None) + } + + // If we're executing via Flatpak then we can't do + // traditional process watching (its implemented + // as a special case in os/flatpak) since the + // command is on the host. + Some(Process::Flatpak(fp)) => { + let (tx, rx) = crossbeam_channel::bounded(1); + let wakeup = Wakeup::new()?; + if let Some(cmd) = fp.cmd.take() { + let w = wakeup.clone(); + cmd.wait_callback(move |r| { + let _ = tx.send(r.map_err(|e| format!("{e}"))); + let _ = w.notify(); + })?; + } + (ProcessWatch::Flatpak, Some(FlatpakEvents { rx, wakeup })) + } + + None => return Err(Error::ProcessNotStarted), + }; + + // Track our process start time for abnormal exits + let start = Instant::now(); + + // Create our pipe that we'll use to kill our read thread. + // pipe.0 is the read end, pipe.1 is the write end. + let (quit_read, quit_write) = ghostty_os::pipe::pipe()?; + + // The pty fd is shared (same open file description) with the read + // thread, which sets it to non-blocking; we need non-blocking writes + // too, so set it before the thread starts. + set_nonblocking(&read_fd); + + // Start our read thread + let read_thread = std::thread::Builder::new() + .name("io-reader".into()) + .spawn(move || read_thread_main(&read_fd, &output, &quit_read))?; + + Ok(ThreadData { + start, + exited: false, + pty_fd: write_fd, + write_queue: VecDeque::new(), + write_offset: 0, + watch, + flatpak_events, + read_thread: Some(read_thread), + read_thread_pipe: Some(quit_write), + // Start our termios timer. This is used to detect things such + // as when password input is being done so we can render the + // terminal in a different way. + termios_deadline: Some(Instant::now() + Duration::from_millis(TERMIOS_POLL_MS)), + termios_timer_running: true, + termios_mode: Mode::default(), + surface_mailbox, + renderer_state, + }) + } + + pub fn thread_exit(&mut self, td: &mut ThreadData) { + if td.exited { + self.subprocess.external_exit(); + } + self.subprocess.stop(); + + // Quit our read thread after exiting the subprocess so that + // we don't get stuck waiting for data to stop flowing if it is + // a particularly noisy process. + if let Some(pipe) = td.read_thread_pipe.take() { + match rustix::io::write(&pipe, b"x") { + Ok(_) => {} + // BrokenPipe means that our read thread is closed already, + // which is completely fine since that is what we were trying + // to achieve. + Err(rustix::io::Errno::PIPE) => {} + Err(err) => log::warn!("error writing to read thread quit pipe err={err}"), + } + } + + if let Some(t) = td.read_thread.take() + && t.join().is_err() + { + log::warn!("read thread panicked"); + } + } + + pub fn focus_gained(&mut self, td: &mut ThreadData, focused: bool) { + if focused { + // Always set this to true. There is a race condition if we lose + // focus and regain focus before the termios timer ticks where + // if we don't set this unconditionally the timer will end on + // the next iteration. + td.termios_timer_running = true; + + // If we're focused, we want to start our termios timer. We + // only do this if it isn't already running. We use the termios + // callback because that'll trigger an immediate state check AND + // start the timer. + if td.termios_deadline.is_none() { + td.termios_tick(); + } + } else { + // Flag the timer to end on the next iteration. This is + // a lot cheaper than doing full timer cancellation. + td.termios_timer_running = false; + } + } + + pub fn resize(&mut self, grid_size: GridSize, screen_size: ScreenSize) -> Result<(), Error> { + self.subprocess.resize(grid_size, screen_size) + } + + pub fn queue_write(&mut self, td: &mut ThreadData, data: &[u8], linefeed: bool) { + td.queue_write(data, linefeed); + } +} + +/// How we notice the child exiting. +enum ProcessWatch { + /// A pidfd that becomes readable when the process exits. + PidFd(OwnedFd, i32), + /// Fallback: `waitpid(WNOHANG)` every [`PROCESS_POLL_MS`]. + Poll(i32, Instant), + /// Flatpak host command: events arrive through `flatpak_events`. + Flatpak, + /// Already exited (or never watched). + Done, +} + +impl ProcessWatch { + fn for_pid(pid: i32) -> ProcessWatch { + let Some(p) = Pid::from_raw(pid) else { + return ProcessWatch::Done; + }; + match rustix::process::pidfd_open(p, PidfdFlags::empty()) { + Ok(fd) => ProcessWatch::PidFd(fd, pid), + Err(err) => { + log::warn!("pidfd_open failed ({err}), falling back to polling waitpid"); + ProcessWatch::Poll(pid, Instant::now() + Duration::from_millis(PROCESS_POLL_MS)) + } + } + } +} + +struct FlatpakEvents { + rx: Receiver>, + wakeup: Wakeup, +} + +/// The thread local data for the exec implementation. +pub struct ThreadData { + /// Process start time and boolean of whether its already exited. + start: Instant, + pub exited: bool, + + /// A dup of the pty master used for writes and termios queries. + pty_fd: OwnedFd, + + /// Pending writes to the pty (the write queue of the `xev.Stream`) and + /// the offset into the front chunk. + write_queue: VecDeque>, + write_offset: usize, + + watch: ProcessWatch, + flatpak_events: Option, + + /// Reader thread state + read_thread: Option>, + read_thread_pipe: Option, + + /// The termios timer: `None` when not armed. + termios_deadline: Option, + termios_timer_running: bool, + + /// The last known termios mode. Used for change detection + /// to prevent unnecessary locking of expensive mutexes. + termios_mode: Mode, + + surface_mailbox: Arc, + renderer_state: Arc, +} + +impl ThreadData { + /// The pty fd, for `POLLOUT` polling and tests. + pub fn pty_fd(&self) -> BorrowedFd<'_> { + self.pty_fd.as_fd() + } + + /// True if there are queued writes that need a `POLLOUT` wakeup. + pub fn wants_write(&self) -> bool { + !self.exited && !self.write_queue.is_empty() + } + + /// An fd that becomes readable when the child exited. + pub fn process_fd(&self) -> Option> { + match &self.watch { + ProcessWatch::PidFd(fd, _) => Some(fd.as_fd()), + _ => None, + } + } + + /// The wakeup of the Flatpak exit notification, if any. + pub fn flatpak_fd(&self) -> Option> { + self.flatpak_events.as_ref().map(|e| e.wakeup.as_fd()) + } + + /// The next time [`ThreadData::on_timer`] needs to run. + pub fn next_deadline(&self) -> Option { + let poll = match &self.watch { + ProcessWatch::Poll(_, at) => Some(*at), + _ => None, + }; + [self.termios_deadline, poll].into_iter().flatten().min() + } + + /// Queue a write directly to the pty. If the process exited no more + /// writes are sent. With `linefeed` every `\r` is replaced by `\r\n`. + pub fn queue_write(&mut self, data: &[u8], linefeed: bool) { + // If our process is exited then we don't send any more writes. + if self.exited || data.is_empty() { + return; + } + + if linefeed { + let mut buf = Vec::with_capacity(data.len() + 8); + for &ch in data { + buf.push(ch); + if ch == b'\r' { + buf.push(b'\n'); + } + } + self.write_queue.push_back(buf); + } else { + self.write_queue.push_back(data.to_vec()); + } + + self.flush_writes(); + } + + /// Write as much of the queue as the pty accepts right now. + pub fn flush_writes(&mut self) { + while let Some(front) = self.write_queue.front() { + match rustix::io::write(&self.pty_fd, &front[self.write_offset..]) { + Ok(n) => { + self.write_offset += n; + if self.write_offset >= front.len() { + self.write_queue.pop_front(); + self.write_offset = 0; + } + } + Err(rustix::io::Errno::INTR) => {} + // The pty buffer is full: wait for POLLOUT. + Err(rustix::io::Errno::AGAIN) => return, + Err(err) => { + log::error!("write error: {err}"); + self.write_queue.pop_front(); + self.write_offset = 0; + } + } + } + } + + /// The number of bytes queued for writing (for tests/diagnostics). + pub fn queued_bytes(&self) -> usize { + self.write_queue.iter().map(Vec::len).sum::() - self.write_offset + } + + fn process_exit_common(&mut self, exit_code: u32) { + self.exited = true; + self.watch = ProcessWatch::Done; + // Nothing can be written anymore. + self.write_queue.clear(); + self.write_offset = 0; + + // Determine how long the process was running for. + let runtime_ms = u64::try_from(self.start.elapsed().as_millis()).unwrap_or(u64::MAX); + log::debug!("child process exited status={exit_code} runtime={runtime_ms}ms"); + + // We always notify the surface immediately that the child has + // exited and some metadata about the exit. + self.surface_mailbox.push( + SurfaceMessage::ChildExited(ChildExited { + exit_code, + runtime_ms, + }), + Timeout::Forever, + ); + } + + /// Called when `process_fd()` is readable (or the poll fallback fired): + /// reap the child and report the exit. + pub fn on_process_ready(&mut self, cmd_pid: Option) { + let pid = match &self.watch { + ProcessWatch::PidFd(_, pid) | ProcessWatch::Poll(pid, _) => *pid, + _ => return, + }; + let _ = cmd_pid; + let Some(p) = Pid::from_raw(pid) else { + return; + }; + match rustix::process::waitpid(Some(p), WaitOptions::NOHANG) { + Ok(Some((_, status))) => { + let code = exit_code(Exit::init(status.as_raw() as u32)); + self.process_exit_common(code); + } + // Not exited yet (only possible for the polling fallback). + Ok(None) => { + if let ProcessWatch::Poll(_, at) = &mut self.watch { + *at = Instant::now() + Duration::from_millis(PROCESS_POLL_MS); + } + } + Err(err) => { + // Somebody else reaped it. Report a generic exit. + log::warn!("waitpid failed on exit notification err={err}"); + self.process_exit_common(0); + } + } + } + + /// Called when `flatpak_fd()` is readable. + pub fn on_flatpak_ready(&mut self) { + let Some(ev) = &self.flatpak_events else { + return; + }; + ev.wakeup.drain(); + match ev.rx.try_recv() { + Ok(Ok(code)) => self.process_exit_common(u32::from(code)), + Ok(Err(err)) => { + log::error!("error waiting for flatpak host command err={err}"); + self.process_exit_common(1); + } + Err(_) => {} + } + } + + /// Run due timers. + pub fn on_timer(&mut self, now: Instant) { + if let Some(at) = self.termios_deadline + && now >= at + { + self.termios_deadline = None; + self.termios_tick(); + } + if let ProcessWatch::Poll(_, at) = &self.watch + && now >= *at + { + self.on_process_ready(None); + } + } + + /// The termios timer body (`termiosTimer`). + fn termios_tick(&mut self) { + // This is kind of hacky but we only need the master fd to get the + // termios data. + let mode = match pty::get_mode_fd(&self.pty_fd) { + Ok(m) => m, + Err(err) => { + log::warn!("error getting termios mode err={err}"); + // If we have an error we return the default mode values + // which are the likely values. + Mode::default() + } + }; + + // If the mode changed, then we process it. + if mode != self.termios_mode { + log::debug!("termios change mode={mode:?}"); + self.termios_mode = mode; + + // We assume we're in some sort of password input if we're + // in canonical mode and not echoing. This is a heuristic. + let password_input = mode.canonical && !mode.echo; + + // If our password input state changed on the terminal then + // we notify the surface. + let changed = { + let t = self.renderer_state.lock(); + t.flags.password_input != password_input + }; + + if changed { + // We have to notify the surface that we're in password input. + // We must block on this because the balanced true/false state + // of this is critical to apprt behavior. + self.surface_mailbox.push( + SurfaceMessage::PasswordInput(password_input), + Timeout::Forever, + ); + } + } + + // Repeat the timer + if self.termios_timer_running { + self.termios_deadline = Some(Instant::now() + Duration::from_millis(TERMIOS_POLL_MS)); + } + } +} + +/// Convert the way a child exited to the `u32` exit code reported to the +/// surface. libxev reports the exit status for normal exits and the signal +/// number if the child was killed by a signal. +pub fn exit_code(exit: Exit) -> u32 { + match exit { + Exit::Exited(c) => u32::from(c), + Exit::Signal(s) | Exit::Stopped(s) | Exit::Unknown(s) => s, + } +} + +fn set_nonblocking(fd: &OwnedFd) { + let res = rustix::fs::fcntl_getfl(fd) + .and_then(|flags| rustix::fs::fcntl_setfl(fd, flags | rustix::fs::OFlags::NONBLOCK)); + if let Err(err) = res { + log::warn!("read thread failed to set flags err={err}"); + log::warn!("this isn't a fatal error, but may cause performance issues"); + } +} + +/// The read thread sits in a loop doing the following pseudo code: +/// +/// while (true) { blocking_read(); exit_if_eof(); process(); } +/// +/// Almost all terminal-modifying activity is from the pty read, so +/// putting this on a dedicated thread keeps performance very predictable +/// while also almost optimal. "Locking is fast, lock contention is slow." +/// and since we rarely have contention, this is fast. +/// +/// This is also empirically fast compared to putting the read into +/// an async mechanism like io_uring/epoll because the reads are generally +/// small. +/// +/// We use a basic poll syscall here because we are only monitoring two +/// fds and this is still much faster and lower overhead than any async +/// mechanism. +pub fn read_thread_main(fd: &OwnedFd, output: &Output, quit: &OwnedFd) { + // The pty fd is already non-blocking (set before spawning). We try to + // read from the fd in a tight loop and only check the quit fd + // occasionally. + let mut buf = [0u8; 1024]; + let mut hup = false; + loop { + // We try to read from the file descriptor as long as possible + // to maximize performance. We only check the quit fd if the + // main fd blocks. This optimizes for the realistic scenario that + // the data will eventually stop while we're trying to quit. This + // is always true because we kill the process. + loop { + match rustix::io::read(fd, &mut buf) { + // This happens on macOS instead of WouldBlock when the + // child process dies. To be safe, we just break the loop + // and let our poll happen. + Ok(0) => break, + Ok(n) => output.process_output(&buf[..n]), + Err(rustix::io::Errno::INTR) => {} + // No more data, fall back to poll and check for + // exit conditions. + Err(rustix::io::Errno::AGAIN) => break, + // This means our pty is closed. We're probably + // gracefully shutting down. + Err(rustix::io::Errno::IO | rustix::io::Errno::BADF) => { + log::info!("io reader exiting"); + return; + } + Err(err) => { + log::error!("io reader error err={err}"); + return; + } + } + } + + // The pty hung up and we drained everything that was left. + if hup { + log::info!("pty fd closed, read thread exiting"); + return; + } + + // Wait for data. + let mut pollfds = [ + PollFd::new(fd, PollFlags::IN), + PollFd::new(quit, PollFlags::IN), + ]; + loop { + match rustix::event::poll(&mut pollfds, None) { + Ok(_) => break, + Err(rustix::io::Errno::INTR) => {} + Err(err) => { + log::warn!("poll failed on read thread, exiting early err={err}"); + return; + } + } + } + + // If our quit fd is set, we're done. + if pollfds[1].revents().contains(PollFlags::IN) { + log::info!("read thread got quit signal"); + return; + } + + // If our pty fd is closed, then we're also done with our + // read thread. Zig exits immediately here; we first make one more + // pass over the fd so output that raced with the hangup is not lost. + if pollfds[0].revents().contains(PollFlags::HUP) { + hup = true; + } + } +} + +//------------------------------------------------------------------------- +// Subprocess + +/// Union that represents the running process type. +enum Process { + /// Standard POSIX fork/exec + ForkExec(Command), + + /// Flatpak DBus command + Flatpak(FlatpakProcess), +} + +struct FlatpakProcess { + pid: u32, + /// Taken by the exit watcher (`wait_callback` consumes the command). + /// TODO(integration): ghostty-os' `FlatpakHostCommand::wait_callback` + /// takes `self`, so after the watcher is installed we can no longer + /// `signal()` the command from `stop()`. + cmd: Option, +} + +struct Subprocess { + cwd: Option, + env: Option, + args: Vec, + grid_size: GridSize, + screen_size: ScreenSize, + pty: Option, + process: Option, + + rt_pre_exec_info: RtPreExecInfo, + rt_post_fork_info: RtPostForkInfo, + create_scope: Option, + restore_rlimit: Option, +} + +impl Subprocess { + /// Initialize the subprocess. This will NOT start it, this only sets + /// up the internal state necessary to start it later. + fn new(cfg: Config) -> Result { + // Get our env. If a default env isn't provided by the caller + // then we get it ourselves. + let mut env = cfg.env; + + // If we have a resources dir then set our env var + if let Some(dir) = &cfg.resources_dir { + log::info!("found Ghostty resources dir: {dir}"); + env.insert("GHOSTTY_RESOURCES_DIR".into(), dir.clone()); + } + + // Set our TERM var. This is a bit complicated because we want to use + // the ghostty TERM value but we want to only do that if we have + // ghostty in the TERMINFO database. + // + // For now, we just look up a bundled dir but in the future we should + // also load the terminfo database and look for it. + if let Some(base) = &cfg.resources_dir { + env.insert("TERM".into(), cfg.term.clone()); + env.insert("COLORTERM".into(), "truecolor".into()); + + // Assume that the resources directory is adjacent to the terminfo + // database + let parent = std::path::Path::new(base) + .parent() + .map_or_else(String::new, |p| p.to_string_lossy().into_owned()); + env.insert("TERMINFO".into(), format!("{parent}/terminfo")); + } else { + env.insert("TERM".into(), "xterm-256color".into()); + env.insert("COLORTERM".into(), "truecolor".into()); + } + + // Add our binary to the path if we can find it. + add_ghostty_path(&mut env); + + // Set environment variables used by some programs (such as neovim) to detect + // which terminal emulator and version they're running under. + env.insert("TERM_PROGRAM".into(), "ghostty".into()); + env.insert("TERM_PROGRAM_VERSION".into(), VERSION_STRING.into()); + + // VTE_VERSION is set by gnome-terminal and other VTE-based terminals. + // We don't want our child processes to think we're running under VTE. + // This is not apprt-specific, so we do it here. + env.remove("VTE_VERSION"); + + // Setup our shell integration, if we can. + let shell_command: ConfigCommand = 'shell: { + let default_shell_command = cfg + .command + .clone() + .unwrap_or_else(|| ConfigCommand::Shell("sh".into())); + + // Always set up shell features (GHOSTTY_SHELL_FEATURES). These are + // used by both automatic and manual shell integrations. + shell_integration::setup_features( + &mut env, + cfg.shell_integration_features, + cfg.cursor_blink.unwrap_or(true), + ); + + let force: Option = match cfg.shell_integration { + ShellIntegration::None => { + // This is a source of confusion for users despite being + // opt-in since it results in some Ghostty features not + // working. We always want to log it. + log::info!("shell integration disabled by configuration"); + break 'shell default_shell_command; + } + ShellIntegration::Detect => None, + ShellIntegration::Bash => Some(Shell::Bash), + ShellIntegration::Elvish => Some(Shell::Elvish), + ShellIntegration::Fish => Some(Shell::Fish), + ShellIntegration::Nushell => Some(Shell::Nushell), + ShellIntegration::Zsh => Some(Shell::Zsh), + }; + + let Some(dir) = &cfg.resources_dir else { + log::warn!("no resources dir set, shell integration disabled"); + break 'shell default_shell_command; + }; + + let Some(integration) = + shell_integration::setup(dir, &default_shell_command, &mut env, force)? + else { + log::warn!( + "shell could not be detected, no automatic shell integration will be injected" + ); + break 'shell default_shell_command; + }; + + log::info!( + "shell integration automatically injected shell={}", + integration.shell.tag_name() + ); + + integration.command + }; + + // Add the environment variables that override any others. + for (k, v) in cfg.env_override.iter() { + env.insert(k.to_owned(), v.to_owned()); + } + + // Build our args list + let args = exec_command(&shell_command); + + // We have to copy the cwd because there is no guarantee that + // pointers in full_config remain valid. + let cwd = cfg.working_directory; + + // Propagate the current working directory (CWD) to the shell, enabling + // the shell to display the current directory name rather than the + // resolved path for symbolic links. This is important and based + // on the same behavior in Konsole and Kitty (see the linked issues): + // https://bugs.kde.org/show_bug.cgi?id=242114 + // https://github.com/kovidgoyal/kitty/issues/1595 + // https://github.com/ghostty-org/ghostty/discussions/7769 + if let Some(pwd) = &cwd { + env.insert("PWD".into(), pwd.clone()); + } + + Ok(Subprocess { + env: Some(env), + cwd, + args, + + rt_pre_exec_info: cfg.rt_pre_exec_info, + rt_post_fork_info: cfg.rt_post_fork_info, + create_scope: cfg.create_scope, + restore_rlimit: cfg.restore_rlimit, + + // Should be initialized with init_terminal call. + grid_size: GridSize::default(), + screen_size: ScreenSize { + width: 1, + height: 1, + }, + pty: None, + process: None, + }) + } + + /// Start the subprocess. If the subprocess is already started this + /// will panic. Returns `(read, write)` fds (dups of the pty master). + fn start(&mut self) -> Result<(OwnedFd, OwnedFd), Error> { + assert!(self.pty.is_none() && self.process.is_none()); + + // Create our pty + let pty = Pty::open(Winsize { + ws_row: clamp_u16(u32::from(self.grid_size.rows)), + ws_col: clamp_u16(u32::from(self.grid_size.columns)), + ws_xpixel: clamp_u16(self.screen_size.width), + ws_ypixel: clamp_u16(self.screen_size.height), + })?; + self.pty = Some(pty); + + match self.start_process() { + Ok(fds) => { + // Cleanup we only run in our parent when we successfully + // start the process. Once our subcommand is started we can + // close the slave side. This prevents the slave fd from + // being leaked to future children. + if let Some(p) = &mut self.pty { + p.close_slave(); + } + // Successful start we can clear out some memory. + self.env = None; + Ok(fds) + } + Err(err) => { + // Dropping the pty closes both sides. + self.pty = None; + Err(err) + } + } + } + + fn start_process(&mut self) -> Result<(OwnedFd, OwnedFd), Error> { + let pty = self.pty.as_ref().ok_or(Error::ProcessNotStarted)?; + let slave = pty.slave.as_ref().ok_or(Error::ProcessNotStarted)?; + let (master_raw, slave_raw) = pty.raw_fds(); + let slave_raw = slave_raw.unwrap_or(-1); + + log::debug!( + "starting command command={}", + self.args + .iter() + .map(|a| format!("`{a}`")) + .collect::>() + .join(", ") + ); + + let flatpak = ghostty_os::flatpak::is_flatpak(); + + // If we can't access the cwd, then don't set any cwd and inherit. + // This is important because our cwd can be set by the shell (OSC 7) + // and we don't want to break new windows. + let cwd: Option = match &self.cwd { + None => None, + Some(proposed) if flatpak => { + // Flatpak sandboxing prevents access to certain reserved paths + // regardless of configured permissions. Perform a test spawn + // to get around this problem + // + // https://docs.flatpak.org/en/latest/sandbox-permissions.html#reserved-paths + log::info!("flatpak detected, will use host command to verify cwd access"); + let dev_null = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/null")?; + let mk = || dev_null.try_clone().map(OwnedFd::from); + let mut cmd = ghostty_os::flatpak::FlatpakHostCommand::new( + vec!["/bin/sh".into(), "-c".into(), ":".into()], + mk()?, + mk()?, + mk()?, + ); + cmd.cwd = Some(proposed.clone()); + match cmd.spawn() { + Ok(_) => { + let _ = cmd.wait(); + Some(proposed.clone()) + } + Err(err) => { + log::warn!("cannot spawn command at cwd, ignoring: {err}"); + None + } + } + } + Some(proposed) => { + if std::path::Path::new(proposed).exists() { + Some(proposed.clone()) + } else { + log::warn!("cannot access cwd, ignoring: {proposed}"); + None + } + } + }; + + // In flatpak, we use the HostCommand to execute our shell. + if flatpak { + let mut cmd = ghostty_os::flatpak::FlatpakHostCommand::new( + self.args.clone(), + slave.try_clone()?, + slave.try_clone()?, + slave.try_clone()?, + ); + cmd.cwd = cwd; + cmd.env.clone_from(&self.env); + let pid = cmd.spawn().map_err(Error::Flatpak)?; + log::info!( + "started subcommand on host via flatpak API path={} pid={pid}", + self.args[0] + ); + self.process = Some(Process::Flatpak(FlatpakProcess { + pid, + cmd: Some(cmd), + })); + return Ok((pty.master.try_clone()?, pty.master.try_clone()?)); + } + + // Build our subcommand + let pre_info = self.rt_pre_exec_info; + let post_info = self.rt_post_fork_info; + let create_scope = self.create_scope.clone(); + let mut cmd = Command { + path: self.args[0].clone(), + args: self.args.clone(), + env: self.env.clone(), + cwd, + stdin: Some(slave.try_clone()?), + stdout: Some(slave.try_clone()?), + stderr: Some(slave.try_clone()?), + restore_rlimit: self.restore_rlimit, + os_pre_exec: Some(Box::new(move || { + // Runs in the forked child (see `Subprocess.childPreExec`). + // Zig logs the error and continues. + let _ = pty::child_pre_exec(master_raw, slave_raw); + None + })), + rt_pre_exec: Some(Box::new(move || linux_cgroup_pre_exec(&pre_info))), + rt_post_fork: Some(Box::new(move |c: &Command| { + let create = |pid, mem, tasks| match &create_scope { + Some(f) => f(pid, mem, tasks), + None => None, + }; + linux_cgroup_post_fork(c, &post_info, create) + .map_err(|_: PostForkError| PostForkError) + })), + pid: None, + }; + + cmd.start()?; + // The parent must not keep the slave open through the Command + // (Zig stored raw handles that are closed with the slave). + cmd.stdin = None; + cmd.stdout = None; + cmd.stderr = None; + log::info!("started subcommand path={} pid={:?}", self.args[0], cmd.pid); + + let fds = (pty.master.try_clone()?, pty.master.try_clone()?); + self.process = Some(Process::ForkExec(cmd)); + Ok(fds) + } + + /// Called to notify that we exited externally so we can unset our + /// running state. + fn external_exit(&mut self) { + self.process = None; + } + + /// Stop the subprocess. This is safe to call anytime. This will wait + /// for the subprocess to register that it has been signalled, but not + /// for it to terminate, so it will not block. + /// This does not close the pty. + fn stop(&mut self) { + let Some(process) = self.process.take() else { + return; + }; + match process { + Process::ForkExec(cmd) => { + // Note: this will also wait for the command to exit, so + // DO NOT call cmd.wait + if let Err(err) = kill_command(&cmd) { + log::error!("error sending SIGHUP to command, may hang: {err}"); + } + } + Process::Flatpak(fp) => { + if let Some(cmd) = &fp.cmd { + if let Err(err) = cmd.signal(1 /* SIGHUP */, true) { + log::error!("error sending SIGHUP to command, may hang: {err}"); + } + } else { + log::warn!( + "cannot signal flatpak host command pid={} after the watcher took it", + fp.pid + ); + } + } + } + } + + /// Resize the pty subprocess. This is safe to call anytime. + fn resize(&mut self, grid_size: GridSize, screen_size: ScreenSize) -> Result<(), Error> { + self.grid_size = grid_size; + self.screen_size = screen_size; + + if let Some(pty) = &mut self.pty { + // It is theoretically possible for the grid or screen size to + // exceed u16, although the terminal in that case isn't very + // usable. This should be protected upstream but we still clamp + // in case there is a bad caller which has happened before. + pty.set_size(Winsize { + ws_row: clamp_u16(u32::from(grid_size.rows)), + ws_col: clamp_u16(u32::from(grid_size.columns)), + ws_xpixel: clamp_u16(screen_size.width), + ws_ypixel: clamp_u16(screen_size.height), + })?; + } + Ok(()) + } +} + +fn clamp_u16(v: u32) -> u16 { + u16::try_from(v).unwrap_or(u16::MAX) +} + +/// Add the directory of our own binary to `PATH` and set `GHOSTTY_BIN_DIR`. +fn add_ghostty_path(env: &mut EnvMap) { + // Skip this for flatpak since host cannot reach them + if ghostty_os::flatpak::is_flatpak() { + return; + } + + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(err) => { + log::warn!("failed to get ghostty exe path err={err}"); + return; + } + }; + let Some(exe_dir) = exe.parent().map(|p| p.to_string_lossy().into_owned()) else { + return; + }; + log::debug!("appending ghostty bin to path dir={exe_dir}"); + + // We always set this so that if the shell overwrites the path + // scripts still have a way to find the Ghostty binary when + // running in Ghostty. + env.insert("GHOSTTY_BIN_DIR".into(), exe_dir.clone()); + + // Append if we have a path. We want to append so that ghostty is + // the last priority in the path. If we don't have a path set + // then we just set it to the directory of the binary. + if let Some(path) = env.get("PATH").cloned() { + // Verify that our path doesn't already contain this entry + if path + .split(':') + .any(|entry| !entry.is_empty() && entry == exe_dir) + { + return; + } + env.insert("PATH".into(), ghostty_os::env::append_env(&path, &exe_dir)); + } else { + env.insert("PATH".into(), exe_dir); + } +} + +/// Kill the underlying subprocess. This sends a SIGHUP to the child +/// process group. This also waits for the command to exit. +fn kill_command(command: &Command) -> Result<(), Error> { + if let Some(pid) = command.pid { + kill_pid(pid)?; + } + Ok(()) +} + +fn kill_pid(pid_raw: i32) -> Result<(), Error> { + let Some(pid) = Pid::from_raw(pid_raw) else { + return Ok(()); + }; + let Some(pgid) = getpgid(pid) else { + return Ok(()); + }; + + // It is possible to send a killpg between the time that + // our child process calls setsid but before or simultaneous + // to calling execve. In this case, the direct child dies + // but grandchildren survive. To work around this, we loop + // and repeatedly kill the process group until all + // descendents are well and truly dead. We will not rest + // until the entire family tree is obliterated. + loop { + match rustix::process::kill_process_group(pgid, Signal::HUP) { + Ok(()) => log::debug!("process group killed pgid={}", pgid.as_raw_nonzero()), + Err(err) => { + log::warn!( + "error killing process group pgid={} err={err}", + pgid.as_raw_nonzero() + ); + return Err(Error::KillFailed); + } + } + + // See Command::wait for why we specify WNOHANG. + // The gist is that it lets us detect when children + // are still alive without blocking so that we can + // kill them again. + match rustix::process::waitpid(Some(pid), WaitOptions::NOHANG) { + Ok(Some(_)) => { + log::debug!("waitpid result={pid_raw}"); + break; + } + Ok(None) => { + log::debug!("waitpid result=0"); + } + // Already reaped by someone else (e.g. our exit watcher). + Err(rustix::io::Errno::CHILD) => break, + Err(err) => { + log::warn!("waitpid failed err={err}"); + break; + } + } + std::thread::sleep(Duration::from_millis(10)); + } + Ok(()) +} + +fn getpgid(pid: Pid) -> Option { + // Get our process group ID. Before the child pid calls setsid + // the pgid will be ours because we forked it. Its possible that + // we may be calling this before setsid if we are killing a surface + // VERY quickly after starting it. + let my_pgid = rustix::process::getpgid(None).ok(); + + // We loop while pgid == my_pgid. The expectation if we have a valid + // pid is that setsid will eventually be called because it is the + // FIRST thing the child process does and as far as I can tell, + // setsid cannot fail. (Zig loops forever; we give up after ~5s.) + for _ in 0..500 { + match rustix::process::getpgid(Some(pid)) { + Ok(pgid) => { + if Some(pgid) == my_pgid { + log::warn!("pgid is our own, retrying"); + std::thread::sleep(Duration::from_millis(10)); + continue; + } + return Some(pgid); + } + // If the pid doesn't exist then... we're done! + Err(rustix::io::Errno::SRCH) => return None, + // If we have an error we're done. + Err(_) => { + log::warn!("error getting pgid for kill"); + return None; + } + } + } + None +} + +/// Builds the argv array for the process we should exec for the +/// configured command. This isn't as straightforward as it seems since +/// we deal with shell-wrapping, etc. +/// +/// (The macOS `login(1)` branch of the Zig function is not ported, Linux +/// only; neither is the Windows `cmd.exe` branch.) +pub fn exec_command(command: &ConfigCommand) -> Vec { + match command { + // We need to clone the command since there's no guarantee the config remains valid. + ConfigCommand::Direct(args) => args.clone(), + + ConfigCommand::Shell(v) => { + let mut args: Vec = Vec::with_capacity(4); + + // We run our shell wrapped in `/bin/sh` so that we don't have + // to parse the command line ourselves if it has arguments. + // Additionally, some environments (NixOS, I found) use /bin/sh + // to setup some environment variables that are important to + // have set. + args.push("/bin/sh".into()); + if ghostty_os::flatpak::is_flatpak() { + args.push("-l".into()); + } + args.push("-c".into()); + args.push(v.clone()); + args + } + } +} + +impl Drop for ThreadData { + fn drop(&mut self) { + // Join the reader if thread_exit wasn't called (shouldn't happen). + if let Some(pipe) = self.read_thread_pipe.take() { + let _ = rustix::io::write(&pipe, b"x"); + } + if let Some(t) = self.read_thread.take() { + let _ = t.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exec_command_shell_command() { + // execCommand: shell command, empty passwd + let result = exec_command(&ConfigCommand::Shell("foo bar baz".into())); + assert_eq!(result, ["/bin/sh", "-c", "foo bar baz"]); + } + + #[test] + fn exec_command_direct_command() { + // execCommand: direct command, error passwd + let result = exec_command(&ConfigCommand::Direct(vec!["foo".into(), "bar baz".into()])); + assert_eq!(result, ["foo", "bar baz"]); + } + + #[test] + fn exec_command_direct_command_config_freed() { + let result = { + let command = ConfigCommand::Direct(vec!["foo".into(), "bar baz".into()]); + let r = exec_command(&command); + drop(command); + r + }; + assert_eq!(result.len(), 2); + assert_eq!(result[0], "foo"); + assert_eq!(result[1], "bar baz"); + } + + #[test] + fn exit_code_mapping() { + assert_eq!(exit_code(Exit::Exited(3)), 3); + assert_eq!(exit_code(Exit::Signal(9)), 9); + } +} diff --git a/crates/ghostty-termio/src/lib.rs b/crates/ghostty-termio/src/lib.rs index 5ba5cb7..e0e7c60 100644 --- a/crates/ghostty-termio/src/lib.rs +++ b/crates/ghostty-termio/src/lib.rs @@ -1,8 +1,10 @@ //! ghostty-termio: see docs/rewrite/ARCHITECTURE.md for the module map. //! -//! Ported so far (Linux only): `pty.zig`, `Command.zig`, -//! `termio/shell_integration.zig`, the `terminfo/*` source generator. -//! See `docs/rewrite/notes/ghostty-termio-IO1.md`. +//! Linux only. Ported: `pty.zig`, `Command.zig`, the `terminfo/*` source +//! generator and everything in `termio/` (`Options`, `backend`, `message`, +//! `mailbox`, `Termio`, `Thread`, `Exec`, `stream_handler`, +//! `shell_integration`). See `docs/rewrite/notes/ghostty-termio-IO1.md` and +//! `ghostty-termio-IO2.md`. //! //! This crate may use limited `unsafe` (fork/exec in `command`, a few //! async-signal-safe libc calls in `pty`); everything else is safe. @@ -16,10 +18,32 @@ ) )] +pub mod backend; pub mod command; +pub mod exec; +pub mod mailbox; +pub mod message; +pub mod options; pub mod pty; pub mod shell_integration; +pub mod sinks; +pub mod state; +pub mod stream_handler; pub mod terminfo; +pub mod termio; +pub mod thread; +pub mod wakeup; +pub use backend::Backend; pub use command::Command; +pub use exec::Exec; +pub use mailbox::Mailbox; +pub use message::Message; +pub use options::Options; pub use pty::{Pty, Winsize}; +pub use sinks::{RendererMailbox, SurfaceMailbox}; +pub use state::RendererState; +pub use stream_handler::StreamHandler; +pub use termio::{DerivedConfig, Termio}; +pub use thread::Thread; +pub use wakeup::Wakeup; diff --git a/crates/ghostty-termio/src/mailbox.rs b/crates/ghostty-termio/src/mailbox.rs new file mode 100644 index 0000000..387838f --- /dev/null +++ b/crates/ghostty-termio/src/mailbox.rs @@ -0,0 +1,203 @@ +//! The location to where write-related messages are sent +//! (`termio/mailbox.zig`). +//! +//! Zig has a single `spsc` variant: a fixed-capacity blocking queue plus an +//! `xev.Async` wakeup. Here the queue is a bounded `crossbeam-channel` (so a +//! full queue can hand the rejected message back to the sender, which the +//! `BlockingQueue` cannot) and the wakeup is an eventfd ([`Wakeup`]). + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use crossbeam_channel::{Receiver, Sender, TrySendError, bounded}; + +use crate::message::Message; +use crate::wakeup::Wakeup; + +/// Capacity of the queue. Hardcoded to a value that empirically has made +/// sense for Ghostty usage (`BlockingQueue(termio.Message, 64)`). +pub const CAPACITY: usize = 64; + +/// The termio mailbox. Cloning is cheap and shares the same queue; all +/// clones are producers, but only the IO thread should call [`Mailbox::pop`] +/// / [`Mailbox::drain`] (single consumer by convention, like Zig). +#[derive(Clone, Debug)] +pub struct Mailbox { + tx: Sender, + rx: Receiver, + wakeup: Wakeup, +} + +impl Mailbox { + /// Init the SPSC mailbox (`initSPSC`). + pub fn new() -> std::io::Result { + let (tx, rx) = bounded(CAPACITY); + Ok(Mailbox { + tx, + rx, + wakeup: Wakeup::new()?, + }) + } + + /// The wakeup the IO thread waits on. + pub fn wakeup(&self) -> &Wakeup { + &self.wakeup + } + + /// Sends the given message without notifying there are messages. + /// + /// If the queue is full this wakes up the IO thread so it can make room + /// and then blocks until the message is queued. Do NOT call this while + /// holding the terminal lock if the queue may be full (the IO thread may + /// need the lock to make progress); use [`Mailbox::send_locked`] or + /// [`Mailbox::try_send`] in that case. + pub fn send(&self, msg: Message) { + match self.tx.try_send(msg) { + Ok(()) => {} + Err(TrySendError::Full(msg)) => { + if let Err(err) = self.wakeup.notify() { + log::warn!("failed to wake up writer, data will be dropped err={err}"); + return; + } + // Disconnected can't happen: we own a receiver too. + let _ = self.tx.send(msg); + } + Err(TrySendError::Disconnected(_)) => {} + } + } + + /// Like [`Mailbox::send`] for callers that hold a lock (the Zig + /// `mutex` argument): if the queue is full the guard is released while + /// blocking and re-acquired afterwards. + pub fn send_locked<'a, T>( + &self, + msg: Message, + mutex: &'a Mutex, + guard: MutexGuard<'a, T>, + ) -> MutexGuard<'a, T> { + match self.tx.try_send(msg) { + Ok(()) | Err(TrySendError::Disconnected(_)) => guard, + Err(TrySendError::Full(msg)) => { + if let Err(err) = self.wakeup.notify() { + log::warn!("failed to wake up writer, data will be dropped err={err}"); + return guard; + } + // Unlock the renderer state so the writer thread can acquire + // it, then queue our message before continuing. + drop(guard); + let _ = self.tx.send(msg); + mutex.lock().unwrap_or_else(PoisonError::into_inner) + } + } + } + + /// Queue without ever blocking. Returns the message back if the queue is + /// full. + pub fn try_send(&self, msg: Message) -> Result<(), Message> { + match self.tx.try_send(msg) { + Ok(()) => Ok(()), + Err(TrySendError::Full(m) | TrySendError::Disconnected(m)) => Err(m), + } + } + + /// Notify that there are new messages. + pub fn notify(&self) { + if let Err(err) = self.wakeup.notify() { + log::warn!("failed to notify writer, data will be dropped err={err}"); + } + } + + /// Pop one message without blocking (IO thread). + pub fn pop(&self) -> Option { + self.rx.try_recv().ok() + } + + /// Pop everything currently queued (IO thread). + pub fn drain(&self) -> impl Iterator + '_ { + std::iter::from_fn(|| self.pop()) + } + + /// Number of queued messages. + pub fn len(&self) -> usize { + self.rx.len() + } + + pub fn is_empty(&self) -> bool { + self.rx.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn send_pop_order() { + let mb = Mailbox::new().unwrap(); + mb.send(Message::Focused(true)); + mb.send(Message::LinefeedMode(false)); + assert_eq!(mb.len(), 2); + assert!(matches!(mb.pop(), Some(Message::Focused(true)))); + assert!(matches!(mb.pop(), Some(Message::LinefeedMode(false)))); + assert!(mb.pop().is_none()); + } + + #[test] + fn try_send_returns_message_when_full() { + let mb = Mailbox::new().unwrap(); + for _ in 0..CAPACITY { + mb.try_send(Message::Crash).unwrap(); + } + let back = mb.try_send(Message::Focused(true)).unwrap_err(); + assert!(matches!(back, Message::Focused(true))); + } + + #[test] + fn blocking_send_wakes_consumer_and_completes() { + let mb = Mailbox::new().unwrap(); + for _ in 0..CAPACITY { + mb.send(Message::Crash); + } + let consumer = mb.clone(); + let t = std::thread::spawn(move || { + // Wait for the wakeup the full-queue path posts, then drain. + let mut fds = [rustix::event::PollFd::from_borrowed_fd( + consumer.wakeup().as_fd(), + rustix::event::PollFlags::IN, + )]; + rustix::event::poll(&mut fds, None).unwrap(); + consumer.wakeup().drain(); + consumer.drain().count() + }); + mb.send(Message::Focused(false)); // blocks until the consumer drains + let n = t.join().unwrap(); + assert!(n >= CAPACITY); + } + + #[test] + fn send_locked_releases_lock_when_full() { + let mb = Mailbox::new().unwrap(); + for _ in 0..CAPACITY { + mb.send(Message::Crash); + } + let m = Arc::new(Mutex::new(0u32)); + let consumer = mb.clone(); + let m2 = Arc::clone(&m); + // The consumer needs the lock to make progress (like the IO thread). + let t = std::thread::spawn(move || { + let mut fds = [rustix::event::PollFd::from_borrowed_fd( + consumer.wakeup().as_fd(), + rustix::event::PollFlags::IN, + )]; + rustix::event::poll(&mut fds, None).unwrap(); + let mut g = m2.lock().unwrap(); + *g += 1; + consumer.drain().count() + }); + let g = m.lock().unwrap(); + let g = mb.send_locked(Message::Focused(true), &m, g); + assert!(*g <= 1); + drop(g); + t.join().unwrap(); + } +} diff --git a/crates/ghostty-termio/src/message.rs b/crates/ghostty-termio/src/message.rs new file mode 100644 index 0000000..b3444fc --- /dev/null +++ b/crates/ghostty-termio/src/message.rs @@ -0,0 +1,137 @@ +//! The messages that can be sent to an IO thread (`termio/message.zig`). + +use ghostty_datastruct::MessageData; +use ghostty_renderer::size::Size; +use ghostty_terminal::terminal::ScrollViewport; + +use crate::termio::DerivedConfig; + +/// Represents a write request. Magic number comes from the largest +/// other union value. It can be upped if we add a larger union member +/// in the future. +pub type WriteReq = MessageData; + +/// The types of size reports that we support. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SizeReport { + Mode2048, + Csi14T, + Csi16T, + Csi18T, +} + +/// The messages that can be sent to an IO thread. +/// +/// Zig has three write variants (`write_small`, `write_stable`, +/// `write_alloc`); in Rust they are the three variants of the single +/// [`WriteReq`] (`MessageData`) enum carried by [`Message::Write`]. +#[derive(Debug, Clone)] +pub enum Message { + /// Request a color scheme report is sent to the pty. + ColorSchemeReport { + /// Force write the current color scheme + force: bool, + }, + + /// Purposely crash the renderer. This is used for testing and debugging. + /// See the "crash" binding action. + Crash, + + /// The derived configuration to update the implementation with. + ChangeConfig(Box), + + /// Activate or deactivate the inspector. + Inspector(bool), + + /// Resize the window. + Resize(Size), + + /// Request a size report is sent to the pty ([in-band + /// size report, mode 2048](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) and + /// [XTWINOPS](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-Ps;Ps;Ps-t.1EB0)). + SizeReport(SizeReport), + + /// Clear the screen. + ClearScreen { + /// Include clearing the history + history: bool, + }, + + /// Scroll the viewport + ScrollViewport(ScrollViewport), + + /// Selection scrolling. If this is set to true then the termio + /// thread starts a timer that will trigger a `SelectionScrollTick` + /// message back to the surface. This ping/pong is because the + /// surface thread doesn't have access to an event loop from libghostty. + SelectionScroll(bool), + + /// Jump forward/backward n prompts. + JumpToPrompt(isize), + + /// Send this when a synchronized output mode is started. This will + /// start the timer so that the output mode is disabled after a + /// period of time so that a bad actor can't hang the terminal. + StartSynchronizedOutput, + + /// Enable or disable linefeed mode (mode 20). + LinefeedMode(bool), + + /// The surface gained or lost focus. + Focused(bool), + + /// Write data to the pty (`write_small` / `write_stable` / `write_alloc`). + Write(WriteReq), +} + +impl Message { + /// Return a write request for the given data. This will use the small + /// variant if it fits or allocate otherwise. This should NOT be used for + /// stable (`'static`) data, use [`Message::write_stable`] for that. + pub fn write_req(data: &[u8]) -> Message { + Message::Write(WriteReq::init(data)) + } + + /// A write request for data with a stable pointer (`write_stable`). + pub fn write_stable(data: &'static [u8]) -> Message { + Message::Write(WriteReq::Stable(data)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Zig asserts `@sizeOf(Message) == 40`. The Rust layout differs (a tag + // plus the 38-byte inline buffer, a Vec, and boxed config), so we only + // guard against accidental growth. + #[test] + fn message_size_does_not_grow() { + assert!( + std::mem::size_of::() <= 72, + "Message grew to {} bytes", + std::mem::size_of::() + ); + } + + #[test] + fn write_req_small_vs_alloc() { + let Message::Write(w) = Message::write_req(b"hi") else { + panic!() + }; + assert!(matches!(w, WriteReq::Small { .. })); + assert_eq!(w.slice(), b"hi"); + + let big = vec![b'x'; 100]; + let Message::Write(w) = Message::write_req(&big) else { + panic!() + }; + assert!(matches!(w, WriteReq::Alloc(_))); + assert_eq!(w.slice(), &big[..]); + + let Message::Write(w) = Message::write_stable(b"abc") else { + panic!() + }; + assert!(matches!(w, WriteReq::Stable(_))); + } +} diff --git a/crates/ghostty-termio/src/options.rs b/crates/ghostty-termio/src/options.rs new file mode 100644 index 0000000..b45c955 --- /dev/null +++ b/crates/ghostty-termio/src/options.rs @@ -0,0 +1,47 @@ +//! The options that are used to configure a terminal IO implementation +//! (`termio/Options.zig`). + +use std::sync::Arc; + +use ghostty_config::config::Config; +use ghostty_renderer::size::Size; + +use crate::backend::Backend; +use crate::mailbox::Mailbox; +use crate::sinks::{RendererMailbox, SurfaceMailbox}; +use crate::termio::DerivedConfig; +use crate::wakeup::Wakeup; + +/// See the module docs. +pub struct Options<'a> { + /// All size metrics for the terminal. + pub size: Size, + + /// The full app configuration. This is only available during + /// initialization; any values needed later must be copied. + pub full_config: &'a Config, + + /// The derived configuration for this termio implementation. + pub config: DerivedConfig, + + /// The backend for termio that implements where reads/writes are sourced. + pub backend: Backend, + + /// The mailbox for the terminal. This is how messages are delivered. + /// If you're using `termio::Thread` this MUST be "mailbox" (keep a clone + /// to send messages to the IO thread). + pub mailbox: Mailbox, + + /// A handle to wake up the renderer. This hints to the renderer that that + /// a repaint should happen. + /// + /// (The render state itself is created by `Termio::init`; get it with + /// `Termio::renderer_state()`.) + pub renderer_wakeup: Wakeup, + + /// The mailbox for renderer messages. + pub renderer_mailbox: Arc, + + /// The mailbox for sending the surface messages. + pub surface_mailbox: Arc, +} diff --git a/crates/ghostty-termio/src/pty.rs b/crates/ghostty-termio/src/pty.rs index 47b5cd5..848c8ad 100644 --- a/crates/ghostty-termio/src/pty.rs +++ b/crates/ghostty-termio/src/pty.rs @@ -143,11 +143,7 @@ impl Pty { /// Get the modes of the pty (read from the master). pub fn get_mode(&self) -> Result { - let attrs = termios::tcgetattr(&self.master).map_err(|_| Error::GetModeFailed)?; - Ok(Mode { - canonical: attrs.local_modes.contains(termios::LocalModes::ICANON), - echo: attrs.local_modes.contains(termios::LocalModes::ECHO), - }) + get_mode_fd(&self.master) } /// Return the size of the pty. @@ -178,6 +174,16 @@ impl Pty { } } +/// Read the termios mode from a pty master fd (what Exec's termios timer +/// does by rebuilding a `Pty` around the master in Zig). +pub fn get_mode_fd(master: impl std::os::fd::AsFd) -> Result { + let attrs = termios::tcgetattr(master).map_err(|_| Error::GetModeFailed)?; + Ok(Mode { + canonical: attrs.local_modes.contains(termios::LocalModes::ICANON), + echo: attrs.local_modes.contains(termios::LocalModes::ECHO), + }) +} + /// Signals reset to their default disposition in the child. const RESET_SIGNALS: [nix::sys::signal::Signal; 13] = { use nix::sys::signal::Signal as S; diff --git a/crates/ghostty-termio/src/shell_integration.rs b/crates/ghostty-termio/src/shell_integration.rs index 1a4b050..7ec2f3c 100644 --- a/crates/ghostty-termio/src/shell_integration.rs +++ b/crates/ghostty-termio/src/shell_integration.rs @@ -42,120 +42,8 @@ impl Shell { } } -/// Local stand-in for `config.Command`. -/// TODO(integration): replace with `ghostty_config::Command`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Command { - /// A shell command line, split with `ArgIteratorGeneral` rules. - Shell(String), - /// Directly executed argv. - Direct(Vec), -} - -impl Command { - /// The arguments of the command. For shell commands this is a best - /// effort parse equivalent to Zig's `std.process.ArgIteratorGeneral(.{})` - /// (whitespace separated, double quotes group, backslashes only special - /// before a quote). - pub fn arg_iterator(&self) -> std::vec::IntoIter { - match self { - Command::Direct(v) => v.clone().into_iter(), - Command::Shell(s) => split_args_general(s).into_iter(), - } - } -} - -/// `std.process.ArgIteratorGeneral(.{})` (Windows command-line rules). -fn split_args_general(cmd_line: &str) -> Vec { - let b = cmd_line.as_bytes(); - let mut i = 0; - let mut out = Vec::new(); - loop { - // Skip whitespace - while i < b.len() && (b[i] == b' ' || b[i] == b'\t') { - i += 1; - } - if i >= b.len() { - return out; - } - let mut buf: Vec = Vec::new(); - let mut backslashes = 0usize; - let mut in_quote = false; - loop { - let c = b.get(i).copied(); - match c { - None => { - buf.extend(std::iter::repeat_n(b'\\', backslashes)); - break; - } - Some(b' ' | b'\t') if !in_quote => { - buf.extend(std::iter::repeat_n(b'\\', backslashes)); - i += 1; - break; - } - Some(b'\\') => { - backslashes += 1; - i += 1; - } - Some(b'"') => { - let real = backslashes.is_multiple_of(2); - buf.extend(std::iter::repeat_n(b'\\', backslashes / 2)); - backslashes = 0; - if real { - in_quote = !in_quote; - } else { - buf.push(b'"'); - } - i += 1; - } - Some(ch) => { - buf.extend(std::iter::repeat_n(b'\\', backslashes)); - backslashes = 0; - buf.push(ch); - i += 1; - } - } - } - out.push(String::from_utf8_lossy(&buf).into_owned()); - } -} - -/// Local stand-in for `config.ShellIntegrationFeatures` (same defaults). -/// TODO(integration): replace with `ghostty_config::ShellIntegrationFeatures`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ShellIntegrationFeatures { - pub cursor: bool, - pub sudo: bool, - pub title: bool, - pub ssh_env: bool, - pub ssh_terminfo: bool, - pub path: bool, -} - -impl Default for ShellIntegrationFeatures { - fn default() -> Self { - Self { - cursor: true, - sudo: false, - title: true, - ssh_env: false, - ssh_terminfo: false, - path: true, - } - } -} - -impl ShellIntegrationFeatures { - /// All features disabled (`std.mem.zeroes`). - pub const NONE: Self = Self { - cursor: false, - sudo: false, - title: false, - ssh_env: false, - ssh_terminfo: false, - path: false, - }; -} +pub use ghostty_config::command::Command; +pub use ghostty_config::types_b::ShellIntegrationFeatures; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -486,6 +374,15 @@ fn setup_zsh(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option< #[cfg(test)] mod tests { + const NONE_FEATURES: ShellIntegrationFeatures = ShellIntegrationFeatures { + cursor: false, + sudo: false, + title: false, + ssh_env: false, + ssh_terminfo: false, + path: false, + }; + use ghostty_os::TempDir; use super::*; @@ -602,7 +499,7 @@ mod tests { // all features disabled let mut env = EnvMap::new(); - setup_features(&mut env, ShellIntegrationFeatures::NONE, true); + setup_features(&mut env, NONE_FEATURES, true); assert!(!env.contains_key("GHOSTTY_SHELL_FEATURES")); // mixed features @@ -612,7 +509,7 @@ mod tests { ShellIntegrationFeatures { sudo: true, ssh_env: true, - ..ShellIntegrationFeatures::NONE + ..NONE_FEATURES }, true, ); @@ -621,7 +518,7 @@ mod tests { // blinking cursor let only_cursor = ShellIntegrationFeatures { cursor: true, - ..ShellIntegrationFeatures::NONE + ..NONE_FEATURES }; let mut env = EnvMap::new(); setup_features(&mut env, only_cursor, true); diff --git a/crates/ghostty-termio/src/sinks.rs b/crates/ghostty-termio/src/sinks.rs new file mode 100644 index 0000000..7b808b5 --- /dev/null +++ b/crates/ghostty-termio/src/sinks.rs @@ -0,0 +1,37 @@ +//! The outward-facing mailboxes termio needs from the rest of the program. +//! +//! In Zig these are concrete types (`apprt.surface.Mailbox`, +//! `renderer.Thread.Mailbox`). The renderer thread is not ported yet and the +//! surface mailbox is generic over the core's surface handle and app +//! mailbox, so termio only depends on these two small traits. + +use ghostty_apprt::surface::{AppMailbox, Mailbox as ApprtMailbox, Message as SurfaceMessage}; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_renderer::message::Message as RendererMessage; + +/// The mailbox for sending messages to the surface +/// (`apprt.surface.Mailbox`). +/// +/// `push` returns the queue size after the push; zero means the push failed +/// (only possible with [`Timeout::Instant`] on a full queue). +pub trait SurfaceMailbox: Send + Sync { + fn push(&self, msg: SurfaceMessage, timeout: Timeout) -> usize; +} + +impl SurfaceMailbox for ApprtMailbox +where + S: Clone + Send + Sync, + A: AppMailbox + Send + Sync, +{ + fn push(&self, msg: SurfaceMessage, timeout: Timeout) -> usize { + ApprtMailbox::push(self, msg, timeout) + } +} + +/// The mailbox for sending messages to the renderer thread +/// (`renderer.Thread.Mailbox`). The renderer thread also has a wakeup handle +/// (see [`crate::Options::renderer_wakeup`]) which termio signals to hint +/// that a redraw should happen. +pub trait RendererMailbox: Send + Sync { + fn push(&self, msg: RendererMessage, timeout: Timeout) -> usize; +} diff --git a/crates/ghostty-termio/src/state.rs b/crates/ghostty-termio/src/state.rs new file mode 100644 index 0000000..32b5463 --- /dev/null +++ b/crates/ghostty-termio/src/state.rs @@ -0,0 +1,37 @@ +//! The shared render state (`renderer/State.zig`, the part termio needs). +//! +//! In Zig the terminal is owned by `Termio` and `renderer.State` holds a +//! mutex plus a pointer to it. In Rust the terminal lives inside the mutex, +//! shared via `Arc` between the IO thread, the pty read thread, the +//! surface (core) thread and the renderer thread. +//! +//! TODO(integration): `renderer/State.zig` is deferred in ghostty-renderer; +//! when it lands, this type should move there (and gain `inspector`, +//! `preedit`, `mouse`). + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use ghostty_terminal::terminal::Terminal; + +/// The state shared with the renderer. +#[derive(Debug)] +pub struct RendererState { + /// The terminal emulator state. Hold the lock for as short as possible; + /// it is contended by the pty read thread. + pub terminal: Mutex, +} + +impl RendererState { + pub fn new(terminal: Terminal) -> Self { + Self { + terminal: Mutex::new(terminal), + } + } + + /// Lock the terminal. Lock poisoning is ignored: a panic on another + /// thread must not take the whole terminal down with it (Zig mutexes + /// don't poison). + pub fn lock(&self) -> MutexGuard<'_, Terminal> { + self.terminal.lock().unwrap_or_else(PoisonError::into_inner) + } +} diff --git a/crates/ghostty-termio/src/stream_handler.rs b/crates/ghostty-termio/src/stream_handler.rs new file mode 100644 index 0000000..28ad4bf --- /dev/null +++ b/crates/ghostty-termio/src/stream_handler.rs @@ -0,0 +1,2186 @@ +//! The side-effecting stream handler (`termio/stream_handler.zig`). +//! +//! This is the handler used by termio for the VT [`Stream`]. It applies every +//! terminal-modifying action to the [`Terminal`] (the same behaviour as +//! `ghostty_terminal::stream_readonly`, plus the termio-specific extras such +//! as the default cursor style and the cursor-blink override) and additionally +//! handles everything that has side effects outside of the terminal: +//! query replies written back to the pty (through the termio mailbox), +//! surface notifications (title, pwd, clipboard, bell, progress, ...) and the +//! DCS/APC sub-protocols (XTGETTCAP, DECRQSS, kitty graphics). +//! +//! # Ownership / threading +//! +//! In Zig the handler holds pointers to the terminal and is wrapped by a +//! long-lived `Stream`. Here the long-lived part is [`TerminalStream`] +//! (the VT parser state plus [`HandlerState`]); on every +//! [`TerminalStream::process`] call a short-lived [`StreamHandler`] borrowing +//! the (locked) `Terminal` is built around it. +//! +//! The Zig handler may release the renderer mutex in the middle of an action +//! when a mailbox is full. That is impossible here (the guard is owned by the +//! caller), so a full mailbox instead spills into an in-order *outbox* inside +//! [`HandlerState`], which the caller flushes after unlocking +//! ([`TerminalStream::flush_outbox`]; `Termio::process_output` does this). + +use std::collections::VecDeque; +use std::fmt::Write as _; +use std::sync::{Arc, OnceLock}; + +use ghostty_apprt::structs::Clipboard; +use ghostty_apprt::surface::{ + DesktopNotification, Message as SurfaceMessage, ReportTitleStyle, SET_TITLE_LEN, WriteReq, +}; +use ghostty_config::types_a::CursorStyle as ConfigCursorStyle; +use ghostty_config::types_b::{ClipboardAccess, OSCColorReportFormat}; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_renderer::size::Size; +use ghostty_terminal::cursor::Style as CursorStyle; +use ghostty_terminal::terminal::{ + DeccolmMode, Error, MouseEvents, MouseFormat, MouseShiftCapture, ScrollViewport, + SwitchScreenMode, Terminal, +}; +use ghostty_vt::ansi::{ + CursorStyle as AnsiCursorStyle, DeviceAttributeReq, ModifyKeyFormat, ProtectedMode, +}; +use ghostty_vt::color::{Dynamic as DynamicColor, Rgb}; +use ghostty_vt::csi; +use ghostty_vt::device_status; +use ghostty_vt::kitty::{self, key::SetMode as KittySetMode}; +use ghostty_vt::modes::Mode; +use ghostty_vt::mouse_shape::MouseShape; +use ghostty_vt::osc::color::{self as osc_color, Request, Target}; +use ghostty_vt::osc::semantic_prompt::Action as PromptAction; +use ghostty_vt::osc::{ProgressReport, ProgressState, Terminator}; +use ghostty_vt::parser::{Dcs, Parser}; +use ghostty_vt::stream::{self, Action}; +use ghostty_vt::utf8_decoder::Utf8Decoder; +use ghostty_vt::{apc, dcs}; + +use crate::mailbox::Mailbox; +use crate::message::{Message, SizeReport}; +use crate::sinks::SurfaceMailbox; +use crate::terminfo; +use crate::wakeup::Wakeup; + +/// `build_config.version_string`. +pub const VERSION_STRING: &str = "1.3.1"; + +/// The DECSCUSR style the config/default maps to. +pub fn cursor_style_from_config(style: ConfigCursorStyle) -> CursorStyle { + match style { + ConfigCursorStyle::Bar => CursorStyle::Bar, + ConfigCursorStyle::Block => CursorStyle::Block, + ConfigCursorStyle::Underline => CursorStyle::Underline, + ConfigCursorStyle::BlockHollow => CursorStyle::BlockHollow, + } +} + +/// The long-lived state of the handler (everything but the terminal). +pub struct HandlerState { + /// The cached size info (`size: *renderer.Size`). Kept up to date by + /// `Termio::resize`. + pub size: Size, + + /// Mailbox for data to the termio thread. + pub termio_mailbox: Mailbox, + + /// Mailbox for the surface. + pub surface_mailbox: Arc, + + /// A handle to wake up the renderer. This hints to the renderer that + /// a repaint should happen. + pub renderer_wakeup: Wakeup, + + /// The default cursor state. This is used with CSI q. This is + /// set to true when we're currently in the default cursor state. + pub default_cursor: bool, + pub default_cursor_style: CursorStyle, + pub default_cursor_blink: Option, + + /// The response to use for ENQ requests. + pub enquiry_response: Vec, + + /// The color reporting format for OSC requests. + pub osc_color_report_format: OSCColorReportFormat, + + /// The clipboard write access configuration. + pub clipboard_write: ClipboardAccess, + + //--------------------------------------------------------------- + // Internal state + /// The APC command handler maintains the APC state. APC is like + /// CSI or OSC, but it is a private escape sequence that is used + /// to send commands to the terminal emulator. This is used by + /// the kitty graphics protocol. + pub apc: apc::Handler, + + /// The DCS handler maintains DCS state. DCS is like CSI or OSC, + /// but requires more stateful parsing. This is used by functionality + /// such as XTGETTCAP. + pub dcs: dcs::Handler, + + /// This is set to true when a message was written to the termio + /// mailbox. This can be used by callers to determine if they need + /// to wake up the termio thread. + pub termio_messaged: bool, + + /// This is set to true when we've seen a title escape sequence. We use + /// this to determine if we need to default the window title. + pub seen_title: bool, + + /// Messages that did not fit into a full mailbox (see module docs). + outbox_termio: VecDeque, + outbox_surface: VecDeque, +} + +/// Messages that did not fit into a full mailbox, in order. +#[derive(Default)] +pub struct Outbox { + termio: VecDeque, + surface: VecDeque, +} + +impl Outbox { + pub fn is_empty(&self) -> bool { + self.termio.is_empty() && self.surface.is_empty() + } + + /// Deliver the messages, blocking until the receivers make room. + pub fn deliver(self, termio: &Mailbox, surface: &dyn SurfaceMailbox) { + let any_termio = !self.termio.is_empty(); + for msg in self.termio { + termio.send(msg); + } + if any_termio { + termio.notify(); + } + for msg in self.surface { + surface.push(msg, Timeout::Forever); + } + } +} + +/// The inputs to build a [`HandlerState`]. +pub struct HandlerOptions { + pub size: Size, + pub termio_mailbox: Mailbox, + pub surface_mailbox: Arc, + pub renderer_wakeup: Wakeup, + pub default_cursor_style: CursorStyle, + pub default_cursor_blink: Option, + pub enquiry_response: Vec, + pub osc_color_report_format: OSCColorReportFormat, + pub clipboard_write: ClipboardAccess, +} + +impl HandlerState { + pub fn new(opts: HandlerOptions) -> Self { + Self { + size: opts.size, + termio_mailbox: opts.termio_mailbox, + surface_mailbox: opts.surface_mailbox, + renderer_wakeup: opts.renderer_wakeup, + default_cursor: true, + default_cursor_style: opts.default_cursor_style, + default_cursor_blink: opts.default_cursor_blink, + enquiry_response: opts.enquiry_response, + osc_color_report_format: opts.osc_color_report_format, + clipboard_write: opts.clipboard_write, + apc: apc::Handler::default(), + dcs: dcs::Handler::default(), + termio_messaged: false, + seen_title: false, + outbox_termio: VecDeque::new(), + outbox_surface: VecDeque::new(), + } + } + + /// Queue a render with the renderer thread. The render isn't guaranteed + /// to happen immediately but it will happen as soon as practical. + pub fn queue_render(&self) { + if let Err(err) = self.renderer_wakeup.notify() { + log::warn!("failed to notify renderer err={err}"); + } + } + + fn surface_message_writer(&mut self, msg: SurfaceMessage) { + // See message_writer: preserve order if we already spilled. + if !self.outbox_surface.is_empty() { + self.outbox_surface.push_back(msg); + return; + } + if self.surface_mailbox.push(msg.clone(), Timeout::Instant) == 0 { + self.outbox_surface.push_back(msg); + } + } + + fn message_writer(&mut self, msg: Message) { + self.termio_messaged = true; + if !self.outbox_termio.is_empty() { + self.outbox_termio.push_back(msg); + return; + } + if let Err(msg) = self.termio_mailbox.try_send(msg) { + // Make sure the IO thread is awake to make room. + self.termio_mailbox.notify(); + self.outbox_termio.push_back(msg); + } + } + + /// True if there are messages that did not fit in a mailbox yet. + pub fn has_outbox(&self) -> bool { + !self.outbox_termio.is_empty() || !self.outbox_surface.is_empty() + } + + /// Take the spilled messages out of the handler so they can be delivered + /// after the locks are released (see [`Outbox::deliver`]). + pub fn take_outbox(&mut self) -> Outbox { + Outbox { + termio: std::mem::take(&mut self.outbox_termio), + surface: std::mem::take(&mut self.outbox_surface), + } + } + + /// Deliver the spilled messages, blocking until the receivers make room. + /// MUST NOT be called with the terminal lock held (the receivers may + /// need it to make progress). + pub fn flush_outbox(&mut self) { + let outbox = self.take_outbox(); + outbox.deliver(&self.termio_mailbox, &*self.surface_mailbox); + } + + /// Whether messages were written to the termio mailbox since the last + /// call (and reset the flag), i.e. whether the IO thread needs a wakeup. + pub fn take_termio_messaged(&mut self) -> bool { + std::mem::take(&mut self.termio_messaged) + } +} + +/// The long-lived stream wrapper (`StreamHandler.Stream` plus its handler +/// state): VT parser state that persists across reads. +pub struct TerminalStream { + pub state: HandlerState, + parser: Option, + utf8decoder: Utf8Decoder, +} + +impl TerminalStream { + pub fn new(state: HandlerState) -> Self { + let mut parser = Parser::new(); + // `Stream.initAlloc` + parser.osc_parser.alloc = true; + Self { + state, + parser: Some(parser), + utf8decoder: Utf8Decoder::new(), + } + } + + /// Feed bytes through the parser, applying the actions to `terminal`. + /// Errors from the handler abort the call (Zig `nextSlice` error). + pub fn process(&mut self, terminal: &mut Terminal, bytes: &[u8]) -> Result<(), Error> { + let mut parser = self.parser.take().unwrap_or_else(|| { + let mut p = Parser::new(); + p.osc_parser.alloc = true; + p + }); + let mut s = stream::Stream { + handler: StreamHandler { + terminal, + st: &mut self.state, + }, + parser: std::mem::take(&mut parser), + utf8decoder: self.utf8decoder, + }; + let res = s.next_slice(bytes); + self.parser = Some(s.parser); + self.utf8decoder = s.utf8decoder; + res + } + + /// Scalar variant of [`TerminalStream::process`] (Zig `next` byte by + /// byte; used when an inspector needs to record each byte). + pub fn process_byte(&mut self, terminal: &mut Terminal, byte: u8) -> Result<(), Error> { + let mut s = stream::Stream { + handler: StreamHandler { + terminal, + st: &mut self.state, + }, + parser: self.parser.take().unwrap_or_default(), + utf8decoder: self.utf8decoder, + }; + let res = s.next(byte); + self.parser = Some(s.parser); + self.utf8decoder = s.utf8decoder; + res + } + + /// See [`HandlerState::flush_outbox`]. + pub fn flush_outbox(&mut self) { + self.state.flush_outbox(); + } + + /// Change the configuration for this handler (`changeConfig`). Must be + /// called with the terminal locked. + pub fn change_config( + &mut self, + terminal: &mut Terminal, + config: &crate::termio::DerivedConfig, + ) { + let st = &mut self.state; + st.osc_color_report_format = config.osc_color_report_format; + st.clipboard_write = config.clipboard_write; + st.enquiry_response = config.enquiry_response.clone().into_bytes(); + st.default_cursor_style = cursor_style_from_config(config.cursor_style); + st.default_cursor_blink = config.cursor_blink; + + let mut h = StreamHandler { terminal, st }; + // If our cursor is the default, then we update it immediately. + if h.st.default_cursor { + h.set_cursor_style_default(); + } + + // The config could have changed any of our colors so update mode 2031 + h.st.message_writer(Message::ColorSchemeReport { force: false }); + } +} + +/// The handler (see module docs). +pub struct StreamHandler<'a> { + pub terminal: &'a mut Terminal, + pub st: &'a mut HandlerState, +} + +/// All the xtgettcap responses for the ghostty terminfo (complete DCS +/// replies), keyed by the hex-encoded uppercase capability name. +fn xtgettcap_map() -> &'static std::collections::HashMap> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| { + terminfo::ghostty::GHOSTTY + .xtgettcap_map() + .into_iter() + .map(|(k, v)| (k, v.into_bytes())) + .collect() + }) +} + +impl StreamHandler<'_> { + fn message_writer(&mut self, msg: Message) { + self.st.message_writer(msg); + } + + fn surface_message_writer(&mut self, msg: SurfaceMessage) { + self.st.surface_message_writer(msg); + } + + fn write_req(&mut self, data: &[u8]) { + self.message_writer(Message::write_req(data)); + } + + fn horizontal_tab(&mut self, count: u16) { + for _ in 0..count { + let x = self.terminal.screen().cursor.x; + self.terminal.horizontal_tab(); + if x == self.terminal.screen().cursor.x { + break; + } + } + } + + fn horizontal_tab_back(&mut self, count: u16) { + for _ in 0..count { + let x = self.terminal.screen().cursor.x; + self.terminal.horizontal_tab_back(); + if x == self.terminal.screen().cursor.x { + break; + } + } + } + + fn full_reset(&mut self) { + self.terminal.full_reset(); + self.set_mouse_shape(MouseShape::Text); + + // Reset resets our palette so we report it for mode 2031. + self.message_writer(Message::ColorSchemeReport { force: false }); + + // Clear the progress bar + self.progress_report(ProgressReport { + state: ProgressState::Remove, + progress: None, + }); + } + + fn request_mode(&mut self, mode: Mode) { + let code = if self.terminal.modes.get(mode) { 1 } else { 2 }; + let resp = format!( + "\x1B[{}{};{}$y", + if mode.ansi() { "" } else { "?" }, + mode.value(), + code + ); + self.write_req(resp.as_bytes()); + } + + fn request_mode_unknown(&mut self, mode_raw: u16, ansi: bool) { + let resp = format!("\x1B[{}{};0$y", if ansi { "" } else { "?" }, mode_raw); + self.write_req(resp.as_bytes()); + } + + fn set_mode(&mut self, mode: Mode, enabled: bool) -> Result<(), Error> { + // If we are setting cursor blinking, we ignore it if we have + // a default cursor blink setting set. This is a really weird + // behavior: there are two ways to set cursor blinks, DECSCUSR + // (CSI _ q) and DEC mode 12. DECSCUSR has a way to revert to + // the "default" (CSI 0 q) but mode 12 doesn't, so once a program + // used mode 12 the blink could never be reset to the user's + // `cursor-style-blink`. If the user specified an explicit default + // we ignore mode 12 (DECSCUSR can still change it since programs + // using it are well behaved and reset to the default on exit). + if mode == Mode::CursorBlinking && self.st.default_cursor_blink.is_some() { + return Ok(()); + } + + // We first always set the raw mode on our mode state. + self.terminal.modes.set(mode, enabled); + + // And then some modes require additional processing. + match mode { + // Just noting here that autorepeat has no effect on + // the terminal. xterm ignores this mode and so do we. + Mode::Autorepeat => {} + + // Schedule a render since we changed colors + Mode::ReverseColors => self.terminal.flags.dirty.reverse_colors = true, + + // Origin resets cursor pos. This is called whether or not + // we're enabling or disabling origin mode and whether or + // not the value changed. + Mode::Origin => self.terminal.set_cursor_pos(1, 1), + + Mode::EnableLeftAndRightMargin => { + if !enabled { + // When we disable left/right margin mode we need to + // reset the left/right margins. + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + } + } + + Mode::AltScreenLegacy => self + .terminal + .switch_screen_mode(SwitchScreenMode::M47, enabled)?, + Mode::AltScreen => self + .terminal + .switch_screen_mode(SwitchScreenMode::M1047, enabled)?, + Mode::AltScreenSaveCursorClearEnter => self + .terminal + .switch_screen_mode(SwitchScreenMode::M1049, enabled)?, + + // Mode 1048 is xterm's conditional save cursor depending + // on if alt screen is enabled or not (at the terminal emulator + // level). Alt screen is always enabled for us so this just + // does a save/restore cursor. + Mode::SaveCursor => { + if enabled { + self.terminal.save_cursor(); + } else { + self.terminal.restore_cursor(); + } + } + + // Force resize back to the window size + Mode::EnableMode3 => { + let grid_size = self.st.size.grid(); + if let Err(err) = self.terminal.resize(grid_size.columns, grid_size.rows) { + log::error!("error updating terminal size: {err:?}"); + } + } + + Mode::M132Column => self.terminal.deccolm(if enabled { + DeccolmMode::Cols132 + } else { + DeccolmMode::Cols80 + })?, + + // We need to start a timer to prevent the emulator being hung + // forever. + Mode::SynchronizedOutput => { + if enabled { + self.message_writer(Message::StartSynchronizedOutput); + } + } + + Mode::Linefeed => self.message_writer(Message::LinefeedMode(enabled)), + + Mode::InBandSizeReports => { + if enabled { + self.message_writer(Message::SizeReport(SizeReport::Mode2048)); + } + } + + Mode::FocusEvent => { + if enabled { + let focused = self.terminal.flags.focused; + self.message_writer(Message::Focused(focused)); + } + } + + Mode::MouseEventX10 => self.set_mouse_event(enabled, MouseEvents::X10), + Mode::MouseEventNormal => self.set_mouse_event(enabled, MouseEvents::Normal), + Mode::MouseEventButton => self.set_mouse_event(enabled, MouseEvents::Button), + Mode::MouseEventAny => self.set_mouse_event(enabled, MouseEvents::Any), + + Mode::MouseFormatUtf8 => self.set_mouse_format(enabled, MouseFormat::Utf8), + Mode::MouseFormatSgr => self.set_mouse_format(enabled, MouseFormat::Sgr), + Mode::MouseFormatUrxvt => self.set_mouse_format(enabled, MouseFormat::Urxvt), + Mode::MouseFormatSgrPixels => self.set_mouse_format(enabled, MouseFormat::SgrPixels), + + _ => {} + } + Ok(()) + } + + fn set_mouse_event(&mut self, enabled: bool, ev: MouseEvents) { + if enabled { + self.terminal.flags.mouse_event = ev; + self.set_mouse_shape(MouseShape::Default); + } else { + self.terminal.flags.mouse_event = MouseEvents::None; + self.set_mouse_shape(MouseShape::Text); + } + } + + fn set_mouse_format(&mut self, enabled: bool, f: MouseFormat) { + self.terminal.flags.mouse_format = if enabled { f } else { MouseFormat::X10 }; + } + + fn device_attributes(&mut self, req: DeviceAttributeReq) { + // For the below, we quack as a VT220. We don't quack as + // a 420 because we don't support DCS sequences. + match req { + DeviceAttributeReq::Primary => { + // 62 = Level 2 conformance + // 22 = Color text + // 52 = Clipboard access + let resp: &'static [u8] = if self.st.clipboard_write == ClipboardAccess::Deny { + b"\x1B[?62;22c" + } else { + b"\x1B[?62;22;52c" + }; + self.message_writer(Message::write_stable(resp)); + } + DeviceAttributeReq::Secondary => { + self.message_writer(Message::write_stable(b"\x1B[>1;10;0c")); + } + req @ DeviceAttributeReq::Tertiary => { + log::warn!("unimplemented device attributes req: {req:?}"); + } + } + } + + fn device_status_report(&mut self, req: device_status::Request) { + match req { + device_status::Request::OperatingStatus => { + self.message_writer(Message::write_stable(b"\x1B[0n")); + } + + device_status::Request::CursorPosition => { + let cursor = &self.terminal.screen().cursor; + let (x, y) = if self.terminal.modes.get(Mode::Origin) { + ( + cursor.x.saturating_sub(self.terminal.scrolling_region.left), + cursor.y.saturating_sub(self.terminal.scrolling_region.top), + ) + } else { + (cursor.x, cursor.y) + }; + let resp = format!("\x1B[{};{}R", usize::from(y) + 1, usize::from(x) + 1); + self.write_req(resp.as_bytes()); + } + + device_status::Request::ColorScheme => { + self.message_writer(Message::ColorSchemeReport { force: true }); + } + } + } + + fn set_cursor_style_default(&mut self) { + self.st.default_cursor = true; + self.terminal.screen_mut().cursor.cursor_style = self.st.default_cursor_style; + let blink = self.st.default_cursor_blink.unwrap_or(true); + self.terminal.modes.set(Mode::CursorBlinking, blink); + } + + fn set_cursor_style(&mut self, style: AnsiCursorStyle) { + // Assume we're setting to a non-default. + self.st.default_cursor = false; + + let (cs, blink) = match style { + AnsiCursorStyle::Default => { + self.set_cursor_style_default(); + return; + } + AnsiCursorStyle::BlinkingBlock => (CursorStyle::Block, true), + AnsiCursorStyle::SteadyBlock => (CursorStyle::Block, false), + AnsiCursorStyle::BlinkingUnderline => (CursorStyle::Underline, true), + AnsiCursorStyle::SteadyUnderline => (CursorStyle::Underline, false), + AnsiCursorStyle::BlinkingBar => (CursorStyle::Bar, true), + AnsiCursorStyle::SteadyBar => (CursorStyle::Bar, false), + }; + self.terminal.screen_mut().cursor.cursor_style = cs; + self.terminal.modes.set(Mode::CursorBlinking, blink); + } + + fn query_kitty_keyboard(&mut self) { + log::debug!("querying kitty keyboard mode"); + let resp = format!( + "\x1b[?{}u", + self.terminal.screen().kitty_keyboard.current().int() + ); + self.write_req(resp.as_bytes()); + } + + fn report_xtversion(&mut self) { + log::debug!("reporting XTVERSION: ghostty {VERSION_STRING}"); + let resp = format!("\x1BP>|ghostty {VERSION_STRING}\x1B\\"); + self.write_req(resp.as_bytes()); + } + + //------------------------------------------------------------------------- + // DCS / APC + + fn dcs_command(&mut self, cmd: dcs::Command) { + match cmd { + // TODO(integration): tmux control mode viewer + // (`terminal.tmux.Viewer`) is not ported in ghostty-terminal + // yet; the events are ignored. + dcs::Command::Tmux(tmux) => { + log::info!("tmux control mode event ignored (viewer not ported): {tmux:?}"); + } + + dcs::Command::Xtgettcap(mut gettcap) => { + let map = xtgettcap_map(); + while let Some(key) = gettcap.next() { + let Ok(key) = std::str::from_utf8(key) else { + continue; + }; + let Some(response) = map.get(key) else { + continue; + }; + self.write_req(response); + } + } + + dcs::Command::Decrqss(decrqss) => { + // Our response is valid if we have a response payload + let payload: Option = match decrqss { + // Invalid or unhandled request + dcs::Decrqss::None => None, + + dcs::Decrqss::Sgr => { + let mut s = self.terminal.print_attributes(); + s.push('m'); + Some(s) + } + + dcs::Decrqss::Decscusr => { + let blink = self.terminal.modes.get(Mode::CursorBlinking); + let style: u8 = match self.terminal.screen().cursor.cursor_style { + CursorStyle::Block => { + if blink { + 1 + } else { + 2 + } + } + CursorStyle::Underline => { + if blink { + 3 + } else { + 4 + } + } + CursorStyle::Bar => { + if blink { + 5 + } else { + 6 + } + } + // Below here, the cursor styles aren't represented by + // DECSCUSR so we map it to some other style. + CursorStyle::BlockHollow => { + if blink { + 1 + } else { + 2 + } + } + }; + Some(format!("{style} q")) + } + + dcs::Decrqss::Decstbm => Some(format!( + "{};{}r", + u32::from(self.terminal.scrolling_region.top) + 1, + u32::from(self.terminal.scrolling_region.bottom) + 1 + )), + + dcs::Decrqss::Decslrm => { + // We only send a valid response when left and right + // margin mode (DECLRMM) is enabled. + if self.terminal.modes.get(Mode::EnableLeftAndRightMargin) { + Some(format!( + "{};{}s", + u32::from(self.terminal.scrolling_region.left) + 1, + u32::from(self.terminal.scrolling_region.right) + 1 + )) + } else { + None + } + } + }; + + let valid = payload.as_ref().is_some_and(|p| !p.is_empty()); + let mut response = format!("\x1bP{}$r", u8::from(valid)); + if let Some(p) = payload { + response.push_str(&p); + } + response.push_str("\x1b\\"); + self.write_req(response.as_bytes()); + } + } + } + + fn apc_end(&mut self) { + let Some(cmd) = self.st.apc.end() else { + return; + }; + + match cmd { + apc::Command::Kitty(mut kitty_cmd) => { + if let Some(resp) = self.terminal.kitty_graphics(&mut kitty_cmd) { + let mut buf: Vec = Vec::new(); + if resp.encode(&mut buf).is_ok() && buf.len() > 2 { + log::debug!("kitty graphics response: {buf:x?}"); + self.write_req(&buf); + } + } + } + } + } + + //------------------------------------------------------------------------- + // OSC + + fn window_title(&mut self, title: &str) { + let mut buf = [0u8; SET_TITLE_LEN]; + let title = title.as_bytes(); + if title.len() >= buf.len() { + log::warn!("change title requested larger than our buffer size, ignoring"); + return; + } + + buf[..title.len()].copy_from_slice(title); + + // Special handling for the empty title. We treat the empty title + // as resetting to as if we never saw a title. Other terminals + // behave this way too (e.g. iTerm2). + if title.is_empty() { + // If we have a pwd then we set the title as the pwd else + // we just set it to blank. + if let Some(pwd) = self.terminal.get_pwd() + && pwd.len() < buf.len() + { + buf[..pwd.len()].copy_from_slice(pwd); + buf[pwd.len()] = 0; + } + + self.surface_message_writer(SurfaceMessage::SetTitle(buf)); + self.st.seen_title = false; + return; + } + + self.st.seen_title = true; + self.surface_message_writer(SurfaceMessage::SetTitle(buf)); + } + + fn set_mouse_shape(&mut self, shape: MouseShape) { + // Avoid changing the shape it it is already set to avoid excess + // cross-thread messaging. + if self.terminal.mouse_shape == shape { + return; + } + + self.terminal.mouse_shape = shape; + self.surface_message_writer(SurfaceMessage::SetMouseShape(shape)); + } + + fn clipboard_contents(&mut self, kind: u8, data: &[u8]) { + // Note: we ignore the "kind" field and always use the standard clipboard. + // iTerm also appears to do this but other terminals seem to only allow + // certain. Let's investigate more. + let clipboard_type = match kind { + b's' => Clipboard::Selection, + b'p' => Clipboard::Primary, + // 'c' and anything else + _ => Clipboard::Standard, + }; + + // Get clipboard contents + if data == b"?" { + self.surface_message_writer(SurfaceMessage::ClipboardRead(clipboard_type)); + return; + } + + // Write clipboard contents + self.surface_message_writer(SurfaceMessage::ClipboardWrite { + req: WriteReq::init(data), + clipboard_type, + }); + } + + fn semantic_prompt(&mut self, cmd: &ghostty_vt::osc::SemanticPrompt) -> Result<(), Error> { + match cmd.action { + PromptAction::EndInputStartOutput => { + self.surface_message_writer(SurfaceMessage::StartCommand); + } + + PromptAction::EndCommand => { + // The specification seems to not specify the type but + // other terminals accept 32-bits, but exit codes are really + // bytes, so we just do our best here. + let raw: i32 = cmd.exit_code().unwrap_or(0); + let code: u8 = u8::try_from(raw).unwrap_or(1); + self.surface_message_writer(SurfaceMessage::StopCommand(Some(code))); + } + + // Handled by Terminal, no special handling by us + PromptAction::EndPromptStartInput + | PromptAction::EndPromptStartInputTerminateEol + | PromptAction::FreshLine + | PromptAction::FreshLineNewPrompt + | PromptAction::NewCommand + | PromptAction::PromptStart => {} + } + + // We do this last so failures are still processed correctly + // above. + self.terminal.semantic_prompt(cmd) + } + + fn report_pwd(&mut self, url: &[u8]) -> Result<(), Error> { + // Special handling for the empty URL. We treat the empty URL + // as resetting the pwd as if we never saw a pwd. I can't find any + // other terminal that does this but it seems like a reasonable + // behavior that enables some useful features. For example, the macOS + // proxy icon can be hidden when a program reports it doesn't know + // the pwd rather than showing a stale pwd. + if url.is_empty() { + // Blank value can never fail because no allocs happen. + let _ = self.terminal.set_pwd(b""); + + // If we haven't seen a title, we're using the pwd as our title. + // Set it to blank which will reset our title behavior. + if !self.st.seen_title { + self.window_title(""); + debug_assert!(!self.st.seen_title); + } + + // Report the change. + self.surface_message_writer(SurfaceMessage::PwdChange(WriteReq::Stable(b""))); + return Ok(()); + } + + let Ok(url_str) = std::str::from_utf8(url) else { + log::warn!("invalid url in OSC 7: not utf-8"); + return Ok(()); + }; + + // Attempt to parse this file-style URI using options appropriate + // for this OSC 7 context (e.g. kitty-shell-cwd expects the full, + // unencoded path). + let uri = match ghostty_os::uri::parse( + url_str, + ghostty_os::uri::ParseOptions { + mac_address: true, + raw_path: url_str.starts_with("kitty-shell-cwd://"), + }, + ) { + Ok(uri) => uri, + Err(e) => { + log::warn!("invalid url in OSC 7: {e}"); + return Ok(()); + } + }; + + if uri.scheme != "file" && uri.scheme != "kitty-shell-cwd" { + log::warn!( + "OSC 7 scheme must be file or kitty-shell-cwd, got: {}", + uri.scheme + ); + return Ok(()); + } + + let Some(host) = uri.host.as_ref() else { + log::warn!("OSC 7 uri must contain a hostname: UriMissingHost"); + return Ok(()); + }; + let host = host.to_raw(); + + // OSC 7 is a little sketchy because anyone can send any value from + // any host (such an SSH session). The best practice terminals follow + // is to valid the hostname to be local. + let host_valid = match ghostty_os::hostname::is_local(&host) { + Ok(v) => v, + Err(err) => { + log::warn!("failed to get hostname for OSC 7 validation: {err:?}"); + return Ok(()); + } + }; + if !host_valid { + log::warn!("OSC 7 host ({host}) must be local"); + return Ok(()); + } + + // We need the raw path, which might require unescaping. + let path = uri.path.to_raw().into_owned(); + + log::debug!("terminal pwd: {path}"); + self.terminal.set_pwd(path.as_bytes())?; + + // Report it to the surface. + self.surface_message_writer(SurfaceMessage::PwdChange(WriteReq::init(path.as_bytes()))); + + // If we haven't seen a title, use our pwd as the title. + if !self.st.seen_title { + self.window_title(&path); + self.st.seen_title = false; + } + Ok(()) + } + + fn color_operation(&mut self, requests: &osc_color::List, terminator: Terminator) { + // return early if there is nothing to do + if requests.is_empty() { + return; + } + + let mut response: Vec = Vec::new(); + + for req in requests { + match *req { + Request::Set(set) => { + match set.target { + Target::Palette(i) => { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(i, set.color); + } + Target::Dynamic(dynamic) => match dynamic { + DynamicColor::Foreground => { + self.terminal.colors.foreground.set(set.color); + } + DynamicColor::Background => { + self.terminal.colors.background.set(set.color); + } + DynamicColor::Cursor => self.terminal.colors.cursor.set(set.color), + other => { + log::info!("setting dynamic color {other:?} not implemented"); + } + }, + Target::Special(_) => { + log::info!("setting special colors not implemented"); + } + } + + // Notify the surface of the color change + self.surface_message_writer(SurfaceMessage::ColorChange( + osc_color::ColoredTarget { + target: set.target, + color: set.color, + }, + )); + } + + Request::Reset(target) => match target { + Target::Palette(i) => { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(i); + + let color = self.terminal.colors.palette.current[usize::from(i)]; + self.surface_message_writer(SurfaceMessage::ColorChange( + osc_color::ColoredTarget { target, color }, + )); + } + Target::Dynamic(dynamic) => { + let slot = match dynamic { + DynamicColor::Foreground => Some(&mut self.terminal.colors.foreground), + DynamicColor::Background => Some(&mut self.terminal.colors.background), + DynamicColor::Cursor => Some(&mut self.terminal.colors.cursor), + other => { + log::warn!("resetting dynamic color {other:?} not implemented"); + None + } + }; + if let Some(slot) = slot { + slot.reset(); + if let Some(color) = slot.default { + self.surface_message_writer(SurfaceMessage::ColorChange( + osc_color::ColoredTarget { target, color }, + )); + } + } + } + Target::Special(_) => { + log::info!("resetting special colors not implemented"); + } + }, + + Request::ResetPalette => { + let indices: Vec = self.terminal.colors.palette.mask.iter().collect(); + for i in indices { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(i as u8); + let color = self.terminal.colors.palette.current[i]; + self.surface_message_writer(SurfaceMessage::ColorChange( + osc_color::ColoredTarget { + target: Target::Palette(i as u8), + color, + }, + )); + } + self.terminal.colors.palette.mask = + ghostty_vt::color::PaletteMask::init_empty(); + } + + Request::ResetSpecial => { + log::warn!("resetting all special colors not implemented"); + } + + Request::Query(kind) => { + if self.st.osc_color_report_format == OSCColorReportFormat::None { + continue; + } + + let color: Rgb = match kind { + Target::Palette(i) => self.terminal.colors.palette.current[usize::from(i)], + Target::Dynamic(dynamic) => match dynamic { + DynamicColor::Foreground => { + let Some(c) = self.terminal.colors.foreground.get() else { + continue; + }; + c + } + DynamicColor::Background => { + let Some(c) = self.terminal.colors.background.get() else { + continue; + }; + c + } + DynamicColor::Cursor => { + let Some(c) = self + .terminal + .colors + .cursor + .get() + .or_else(|| self.terminal.colors.foreground.get()) + else { + continue; + }; + c + } + other => { + log::info!("reporting dynamic color {other:?} not implemented"); + continue; + } + }, + Target::Special(_) => { + log::info!("reporting special colors not implemented"); + continue; + } + }; + + let mut s = String::new(); + let (width, scale): (usize, u16) = match self.st.osc_color_report_format { + OSCColorReportFormat::Bit16 => (4, 257), + _ => (2, 1), + }; + let prefix = match kind { + Target::Palette(i) => format!("\x1b]4;{i};"), + Target::Dynamic(d) => format!("\x1b]{};", d as u8), + Target::Special(_) => continue, + }; + let _ = write!( + s, + "{prefix}rgb:{:0width$x}/{:0width$x}/{:0width$x}", + u16::from(color.r) * scale, + u16::from(color.g) * scale, + u16::from(color.b) * scale, + ); + response.extend_from_slice(s.as_bytes()); + response.extend_from_slice(terminator.string()); + } + } + } + + if !response.is_empty() { + // If any of the operations were reports, finalize the report + // string and send it to the terminal. + self.write_req(&response); + } + } + + fn show_desktop_notification(&mut self, title: &[u8], body: &[u8]) { + self.surface_message_writer(SurfaceMessage::DesktopNotification( + DesktopNotification::new(title, body), + )); + } + + /// Send a report to the pty. + fn send_size_report(&mut self, style: csi::SizeReportStyle) { + match style { + csi::SizeReportStyle::Csi14T => { + self.message_writer(Message::SizeReport(SizeReport::Csi14T)); + } + csi::SizeReportStyle::Csi16T => { + self.message_writer(Message::SizeReport(SizeReport::Csi16T)); + } + csi::SizeReportStyle::Csi18T => { + self.message_writer(Message::SizeReport(SizeReport::Csi18T)); + } + csi::SizeReportStyle::Csi21T => { + self.surface_message_writer(SurfaceMessage::ReportTitle(ReportTitleStyle::Csi21T)); + } + } + } + + fn kitty_color_report(&mut self, request: &kitty::color::OSC) { + use kitty::color::{Kind, Request as KReq, Special}; + + let mut out: Vec = Vec::new(); + + for item in &request.list { + match item { + KReq::Query(key) => { + // If the writer buffer is empty, we need to write our prefix + if out.is_empty() { + out.extend_from_slice(b"\x1b]21"); + } + + let color: Option = match key { + Kind::Palette(p) => { + Some(self.terminal.colors.palette.current[usize::from(*p)]) + } + Kind::Special(special) => match special { + Special::Foreground => self.terminal.colors.foreground.get(), + Special::Background => self.terminal.colors.background.get(), + Special::Cursor => self.terminal.colors.cursor.get(), + _ => { + log::warn!("ignoring unsupported kitty color protocol key: {key}"); + continue; + } + }, + }; + let Some(color) = color else { + out.extend_from_slice(format!(";{key}=").as_bytes()); + continue; + }; + + out.extend_from_slice( + format!(";{key}=rgb:{:02x}/{:02x}/{:02x}", color.r, color.g, color.b) + .as_bytes(), + ); + } + KReq::Set { key, color } => match key { + Kind::Palette(palette) => { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(*palette, *color); + } + Kind::Special(special) => match special { + Special::Foreground => self.terminal.colors.foreground.set(*color), + Special::Background => self.terminal.colors.background.set(*color), + Special::Cursor => self.terminal.colors.cursor.set(*color), + _ => { + log::warn!("ignoring unsupported kitty color protocol key: {key}"); + } + }, + }, + KReq::Reset(key) => match key { + Kind::Palette(palette) => { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(*palette); + } + Kind::Special(special) => match special { + Special::Foreground => self.terminal.colors.foreground.reset(), + Special::Background => self.terminal.colors.background.reset(), + Special::Cursor => self.terminal.colors.cursor.reset(), + _ => { + log::warn!("ignoring unsupported kitty color protocol key: {key}"); + } + }, + }, + } + } + + // If we had any writes to our buffer, we queue them now + if !out.is_empty() { + out.extend_from_slice(request.terminator.string()); + self.write_req(&out); + } + + // Note: we don't have to do a queue_render here because every + // processed stream will queue a render once it is done processing + // the read() syscall. + } + + /// Display a GUI progress report. + fn progress_report(&mut self, report: ProgressReport) { + self.surface_message_writer(SurfaceMessage::ProgressReport(report)); + } + + fn dcs_hook(&mut self, d: &Dcs) { + if let Some(cmd) = self.st.dcs.hook(d) { + self.dcs_command(cmd); + } + } + + fn dcs_put(&mut self, byte: u8) { + if let Some(cmd) = self.st.dcs.put(byte) { + self.dcs_command(cmd); + } + } + + fn dcs_unhook(&mut self) { + if let Some(cmd) = self.st.dcs.unhook() { + self.dcs_command(cmd); + } + } +} + +impl stream::Handler for StreamHandler<'_> { + type Error = Error; + + fn vt(&mut self, action: Action<'_>) -> Result<(), Error> { + match action { + Action::Print(v) => self.terminal.print(v.cp)?, + Action::PrintRepeat(v) => self.terminal.print_repeat(v)?, + Action::Bell => self.surface_message_writer(SurfaceMessage::RingBell), + Action::Backspace => self.terminal.backspace(), + Action::HorizontalTab(v) => self.horizontal_tab(v), + Action::HorizontalTabBack(v) => self.horizontal_tab_back(v), + Action::Linefeed | Action::Index => self.terminal.index()?, + Action::CarriageReturn => self.terminal.carriage_return(), + Action::Enquiry => { + log::debug!("sending enquiry response"); + let resp = self.st.enquiry_response.clone(); + self.write_req(&resp); + } + Action::InvokeCharset(v) => { + self.terminal.invoke_charset(v.bank, v.charset, v.locking); + } + Action::CursorUp(v) => self.terminal.cursor_up(usize::from(v.value)), + Action::CursorDown(v) => self.terminal.cursor_down(usize::from(v.value)), + Action::CursorLeft(v) => self.terminal.cursor_left(usize::from(v.value)), + Action::CursorRight(v) => self.terminal.cursor_right(usize::from(v.value)), + Action::CursorPos(v) => self + .terminal + .set_cursor_pos(usize::from(v.row), usize::from(v.col)), + Action::CursorCol(v) => { + let row = usize::from(self.terminal.screen().cursor.y) + 1; + self.terminal.set_cursor_pos(row, usize::from(v.value)); + } + Action::CursorRow(v) => { + let col = usize::from(self.terminal.screen().cursor.x) + 1; + self.terminal.set_cursor_pos(usize::from(v.value), col); + } + Action::CursorColRelative(v) => { + let row = usize::from(self.terminal.screen().cursor.y) + 1; + let col = (usize::from(self.terminal.screen().cursor.x) + 1) + .saturating_add(usize::from(v.value)); + self.terminal.set_cursor_pos(row, col); + } + Action::CursorRowRelative(v) => { + let row = (usize::from(self.terminal.screen().cursor.y) + 1) + .saturating_add(usize::from(v.value)); + let col = usize::from(self.terminal.screen().cursor.x) + 1; + self.terminal.set_cursor_pos(row, col); + } + Action::CursorStyle(v) => self.set_cursor_style(v), + Action::EraseDisplayBelow(p) => { + self.terminal.erase_display(csi::EraseDisplay::Below, p); + } + Action::EraseDisplayAbove(p) => { + self.terminal.erase_display(csi::EraseDisplay::Above, p); + } + Action::EraseDisplayComplete(p) => { + self.terminal.scroll_viewport(ScrollViewport::Bottom); + self.terminal.erase_display(csi::EraseDisplay::Complete, p); + } + Action::EraseDisplayScrollback(p) => { + self.terminal + .erase_display(csi::EraseDisplay::Scrollback, p); + } + Action::EraseDisplayScrollComplete(p) => { + self.terminal + .erase_display(csi::EraseDisplay::ScrollComplete, p); + } + Action::EraseLineRight(p) => self.terminal.erase_line(csi::EraseLine::Right, p), + Action::EraseLineLeft(p) => self.terminal.erase_line(csi::EraseLine::Left, p), + Action::EraseLineComplete(p) => self.terminal.erase_line(csi::EraseLine::Complete, p), + Action::EraseLineRightUnlessPendingWrap(p) => { + self.terminal + .erase_line(csi::EraseLine::RightUnlessPendingWrap, p); + } + Action::DeleteChars(v) => self.terminal.delete_chars(v), + Action::EraseChars(v) => self.terminal.erase_chars(v), + Action::InsertLines(v) => self.terminal.insert_lines(v), + Action::InsertBlanks(v) => self.terminal.insert_blanks(v), + Action::DeleteLines(v) => self.terminal.delete_lines(v), + Action::ScrollUp(v) => self.terminal.scroll_up(v)?, + Action::ScrollDown(v) => self.terminal.scroll_down(v), + Action::TabClearCurrent => self.terminal.tab_clear(csi::TabClear::Current), + Action::TabClearAll => self.terminal.tab_clear(csi::TabClear::All), + Action::TabSet => self.terminal.tab_set(), + Action::TabReset => self.terminal.tab_reset(), + Action::NextLine => { + self.terminal.index()?; + self.terminal.carriage_return(); + } + Action::ReverseIndex => self.terminal.reverse_index(), + Action::FullReset => self.full_reset(), + Action::SetMode(v) => self.set_mode(v.mode, true)?, + Action::ResetMode(v) => self.set_mode(v.mode, false)?, + Action::SaveMode(v) => self.terminal.modes.save(v.mode), + Action::RestoreMode(v) => { + // For restore mode we have to restore but if we set it, we + // always have to call setMode because setting some modes have + // side effects and we want to make sure we process those. + let enabled = self.terminal.modes.restore(v.mode); + self.set_mode(v.mode, enabled)?; + } + Action::RequestMode(v) => self.request_mode(v.mode), + Action::RequestModeUnknown(v) => self.request_mode_unknown(v.mode, v.ansi), + Action::TopAndBottomMargin(v) => { + self.terminal.set_top_and_bottom_margin( + usize::from(v.top_left), + usize::from(v.bottom_right), + ); + } + Action::LeftAndRightMargin(v) => { + self.terminal.set_left_and_right_margin( + usize::from(v.top_left), + usize::from(v.bottom_right), + ); + } + Action::LeftAndRightMarginAmbiguous => { + if self.terminal.modes.get(Mode::EnableLeftAndRightMargin) { + self.terminal.set_left_and_right_margin(0, 0); + } else { + self.terminal.save_cursor(); + } + } + Action::SaveCursor => self.terminal.save_cursor(), + Action::RestoreCursor => self.terminal.restore_cursor(), + Action::ModifyKeyFormat(v) => { + self.terminal.flags.modify_other_keys_2 = false; + if v == ModifyKeyFormat::OtherKeysNumeric { + self.terminal.flags.modify_other_keys_2 = true; + } + } + Action::ProtectedModeOff => self.terminal.set_protected_mode(ProtectedMode::Off), + Action::ProtectedModeIso => self.terminal.set_protected_mode(ProtectedMode::Iso), + Action::ProtectedModeDec => self.terminal.set_protected_mode(ProtectedMode::Dec), + Action::MouseShiftCapture(v) => { + self.terminal.flags.mouse_shift_capture = if v { + MouseShiftCapture::True + } else { + MouseShiftCapture::False + }; + } + Action::SizeReport(v) => self.send_size_report(v), + Action::Xtversion => self.report_xtversion(), + Action::DeviceAttributes(v) => self.device_attributes(v), + Action::DeviceStatus(v) => self.device_status_report(v.request), + Action::KittyKeyboardQuery => self.query_kitty_keyboard(), + Action::KittyKeyboardPush(v) => { + log::debug!("pushing kitty keyboard mode: {:?}", v.flags); + self.terminal.screen_mut().kitty_keyboard.push(v.flags); + } + Action::KittyKeyboardPop(v) => { + log::debug!("popping kitty keyboard mode n={v}"); + self.terminal + .screen_mut() + .kitty_keyboard + .pop(usize::from(v)); + } + Action::KittyKeyboardSet(v) => { + log::debug!("setting kitty keyboard mode: set {:?}", v.flags); + self.terminal + .screen_mut() + .kitty_keyboard + .set(KittySetMode::Set, v.flags); + } + Action::KittyKeyboardSetOr(v) => { + log::debug!("setting kitty keyboard mode: or {:?}", v.flags); + self.terminal + .screen_mut() + .kitty_keyboard + .set(KittySetMode::Or, v.flags); + } + Action::KittyKeyboardSetNot(v) => { + log::debug!("setting kitty keyboard mode: not {:?}", v.flags); + self.terminal + .screen_mut() + .kitty_keyboard + .set(KittySetMode::Not, v.flags); + } + Action::KittyColorReport(v) => self.kitty_color_report(v), + Action::ColorOperation(v) => self.color_operation(v.requests, v.terminator), + Action::EndHyperlink => self.terminal.screen_mut().end_hyperlink(), + Action::ActiveStatusDisplay(v) => self.terminal.status_display = v, + Action::Decaln => self.terminal.decaln()?, + Action::WindowTitle(v) => self.window_title(v.title), + Action::ReportPwd(v) => self.report_pwd(v.url)?, + Action::ShowDesktopNotification(v) => self.show_desktop_notification(v.title, v.body), + Action::ProgressReport(v) => self.progress_report(v), + Action::StartHyperlink(v) => self.terminal.screen_mut().start_hyperlink(v.uri, v.id)?, + Action::ClipboardContents(v) => self.clipboard_contents(v.kind, v.data), + Action::SemanticPrompt(v) => self.semantic_prompt(v)?, + Action::MouseShape(v) => self.set_mouse_shape(v), + Action::ConfigureCharset(v) => self.terminal.configure_charset(v.slot, v.charset), + Action::SetAttribute(v) => { + if let ghostty_vt::sgr::Attribute::Unknown(unk) = &v { + // We optimize for the happy path scenario here, since + // unknown/invalid SGRs aren't that common in the wild. + log::warn!("unimplemented or unknown SGR attribute: {unk:?}"); + } else if let Err(err) = self.terminal.set_attribute(v) { + log::warn!("error setting attribute: {err:?}"); + } + } + Action::DcsHook(v) => self.dcs_hook(&v), + Action::DcsPut(v) => self.dcs_put(v), + Action::DcsUnhook => self.dcs_unhook(), + Action::ApcStart => self.st.apc.start(), + Action::ApcEnd => self.apc_end(), + Action::ApcPut(v) => self.st.apc.feed(v), + + // Unimplemented + Action::TitlePush(_) | Action::TitlePop(_) => {} + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ghostty_terminal::terminal::Options; + use std::sync::Mutex; + + /// Records surface messages. + #[derive(Default)] + struct Rec(Mutex>); + + impl SurfaceMailbox for Rec { + fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize { + let mut v = self.0.lock().unwrap(); + v.push(msg); + v.len() + } + } + + struct Harness { + term: Terminal, + stream: TerminalStream, + mailbox: Mailbox, + surface: Arc, + } + + impl Harness { + fn new() -> Harness { + Self::with(|_| {}) + } + + fn with(f: impl FnOnce(&mut HandlerOptions)) -> Harness { + let mailbox = Mailbox::new().unwrap(); + let surface = Arc::new(Rec::default()); + let mut opts = HandlerOptions { + size: Size::default(), + termio_mailbox: mailbox.clone(), + surface_mailbox: surface.clone(), + renderer_wakeup: Wakeup::new().unwrap(), + default_cursor_style: CursorStyle::Block, + default_cursor_blink: None, + enquiry_response: Vec::new(), + osc_color_report_format: OSCColorReportFormat::Bit16, + clipboard_write: ClipboardAccess::Allow, + }; + f(&mut opts); + let mut term = Terminal::new(Options::new(80, 24)).unwrap(); + term.colors.foreground = DynamicRgb_init(255, 255, 255); + term.colors.background = DynamicRgb_init(0, 0, 0); + Harness { + term, + stream: TerminalStream::new(HandlerState::new(opts)), + mailbox, + surface, + } + } + + fn feed(&mut self, bytes: &[u8]) { + self.stream.process(&mut self.term, bytes).unwrap(); + } + + /// All bytes written to the pty (concatenated write requests). + fn written(&self) -> Vec { + let mut out = Vec::new(); + while let Some(m) = self.mailbox.pop() { + if let Message::Write(w) = m { + out.extend_from_slice(w.slice()); + } + } + out + } + + fn termio_msgs(&self) -> Vec { + std::iter::from_fn(|| self.mailbox.pop()).collect() + } + + fn surface_msgs(&self) -> Vec { + std::mem::take(&mut *self.surface.0.lock().unwrap()) + } + } + + #[allow(non_snake_case)] + fn DynamicRgb_init(r: u8, g: u8, b: u8) -> ghostty_vt::color::DynamicRgb { + ghostty_vt::color::DynamicRgb::init(Rgb { r, g, b }) + } + + #[test] + fn print_updates_terminal() { + let mut h = Harness::new(); + h.feed(b"hello\x1b[31mred"); + assert_eq!("hellored", h.term.plain_string().unwrap()); + } + + #[test] + fn parser_state_persists_across_calls() { + let mut h = Harness::new(); + h.feed(b"ab\x1b[3"); + h.feed(b"1mc\xe2\x82"); + h.feed(b"\xacd"); + assert_eq!("abc\u{20ac}d", h.term.plain_string().unwrap()); + } + + #[test] + fn da1_with_clipboard() { + let mut h = Harness::new(); + h.feed(b"\x1b[c"); + assert_eq!(b"\x1b[?62;22;52c".as_slice(), h.written()); + } + + #[test] + fn da1_clipboard_deny() { + let mut h = Harness::with(|o| o.clipboard_write = ClipboardAccess::Deny); + h.feed(b"\x1b[0c"); + assert_eq!(b"\x1b[?62;22c".as_slice(), h.written()); + } + + #[test] + fn da2() { + let mut h = Harness::new(); + h.feed(b"\x1b[>c"); + assert_eq!(b"\x1b[>1;10;0c".as_slice(), h.written()); + } + + #[test] + fn dsr_operating_status() { + let mut h = Harness::new(); + h.feed(b"\x1b[5n"); + assert_eq!(b"\x1b[0n".as_slice(), h.written()); + } + + #[test] + fn dsr_cursor_position() { + let mut h = Harness::new(); + h.feed(b"\x1b[3;5H\x1b[6n"); + assert_eq!(b"\x1b[3;5R".as_slice(), h.written()); + } + + #[test] + fn dsr_cursor_position_origin_mode() { + let mut h = Harness::new(); + h.feed(b"\x1b[5;20r\x1b[?6h\x1b[2;3H\x1b[6n"); + assert_eq!(b"\x1b[2;3R".as_slice(), h.written()); + } + + #[test] + fn dsr_color_scheme_is_forced_report() { + let mut h = Harness::new(); + h.feed(b"\x1b[?996n"); + let msgs = h.termio_msgs(); + assert!(matches!( + msgs.as_slice(), + [Message::ColorSchemeReport { force: true }] + )); + } + + #[test] + fn xtversion() { + let mut h = Harness::new(); + h.feed(b"\x1b[>q"); + assert_eq!(b"\x1bP>|ghostty 1.3.1\x1b\\".as_slice(), h.written()); + } + + #[test] + fn enquiry_response() { + let mut h = Harness::with(|o| o.enquiry_response = b"ghostty!".to_vec()); + h.feed(b"\x05"); + assert_eq!(b"ghostty!".as_slice(), h.written()); + } + + #[test] + fn enquiry_response_empty_writes_nothing_visible() { + let mut h = Harness::new(); + h.feed(b"\x05"); + assert!(h.written().is_empty()); + } + + #[test] + fn decrqm_known_and_unknown() { + let mut h = Harness::new(); + // Wraparound (7) is on by default. + h.feed(b"\x1b[?7$p"); + assert_eq!(b"\x1b[?7;1$y".as_slice(), h.written()); + h.feed(b"\x1b[?7l\x1b[?7$p"); + assert_eq!(b"\x1b[?7;2$y".as_slice(), h.written()); + h.feed(b"\x1b[?9999$p"); + assert_eq!(b"\x1b[?9999;0$y".as_slice(), h.written()); + } + + #[test] + fn kitty_keyboard_query_and_push() { + let mut h = Harness::new(); + h.feed(b"\x1b[?u"); + assert_eq!(b"\x1b[?0u".as_slice(), h.written()); + h.feed(b"\x1b[>1u\x1b[?u"); + assert_eq!(b"\x1b[?1u".as_slice(), h.written()); + h.feed(b"\x1b[>`), shared with the renderer like Zig's +//! `renderer.State`. +//! +//! Lock order is always terminal mutex first, then the stream mutex inside +//! [`Output`]. + +use std::io::Read; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use ghostty_config::conditional; +use ghostty_config::config::Config; +use ghostty_config::io::ReadableIO; +use ghostty_config::types_a::{Color, CursorStyle as ConfigCursorStyle, TerminalColor}; +use ghostty_config::types_b::{ClipboardAccess, GraphemeWidthMethod, OSCColorReportFormat}; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_renderer::message::Message as RendererMessage; +use ghostty_renderer::size::Size; +use ghostty_terminal::point::{Coordinate, Point}; +use ghostty_terminal::screen::Scroll; +use ghostty_terminal::screen_set::Key as ScreenKey; +use ghostty_terminal::terminal::{Colors, Options as TerminalOptions, ScrollViewport, Terminal}; +use ghostty_vt::color::{DynamicPalette, DynamicRgb, Palette}; +use ghostty_vt::csi; +use ghostty_vt::modes::{Mode, ModePacked}; +use std::sync::MutexGuard; + +use crate::backend::{self, Backend}; +use crate::exec; +use crate::mailbox::Mailbox; +use crate::message::{Message, SizeReport}; +use crate::options::Options; +use crate::sinks::{RendererMailbox, SurfaceMailbox}; +use crate::state::RendererState; +use crate::stream_handler::{ + HandlerOptions, HandlerState, TerminalStream, cursor_style_from_config, +}; +use crate::wakeup::Wakeup; + +/// Errors from termio initialization and thread setup. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Exec(#[from] exec::Error), + #[error("terminal error: {0:?}")] + Terminal(ghostty_terminal::terminal::Error), + #[error("a configured `input` path was not found")] + InputNotFound, + #[error("a configured `input` could not be read or written")] + InputFailed, + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +impl From for Error { + fn from(e: ghostty_terminal::terminal::Error) -> Self { + Error::Terminal(e) + } +} + +/// Mutex state argument for [`Termio::queue_message`] callers that hold the +/// terminal lock (`MutexState`). In Rust the lock is a guard, so this is +/// expressed with `Mailbox::send_locked` instead; the enum is kept for API +/// parity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MutexState { + Locked, + Unlocked, +} + +/// The configuration for this IO that is derived from the main +/// configuration. This must be exported so that we don't need to +/// pass around Config pointers which makes memory management a pain. +#[derive(Debug, Clone)] +pub struct DerivedConfig { + pub palette: Palette, + pub image_storage_limit: usize, + pub cursor_style: ConfigCursorStyle, + pub cursor_blink: Option, + pub cursor_color: Option, + pub foreground: Color, + pub background: Color, + pub osc_color_report_format: OSCColorReportFormat, + pub clipboard_write: ClipboardAccess, + pub enquiry_response: String, + pub conditional_state: conditional::State, +} + +impl DerivedConfig { + pub fn new(config: &Config) -> DerivedConfig { + let palette: Palette = 'palette: { + if config.palette_generate && config.palette.mask.count() > 0 { + // If the user didn't set any values manually, then + // we're using the default palette and we don't need + // to apply the generation code to it. (Handled by the + // count check above.) + break 'palette ghostty_vt::color::generate_256_color( + config.palette.value, + config.palette.mask, + config.background.to_terminal_rgb(), + config.foreground.to_terminal_rgb(), + config.palette_harmonious, + ); + } + + config.palette.value + }; + + DerivedConfig { + palette, + image_storage_limit: config.image_storage_limit as usize, + cursor_style: config.cursor_style, + cursor_blink: config.cursor_style_blink, + cursor_color: config.cursor_color, + foreground: config.foreground, + background: config.background, + osc_color_report_format: config.osc_color_report_format, + clipboard_write: config.clipboard_write, + enquiry_response: config.enquiry_response.clone(), + conditional_state: config.conditional_state, + } + } +} + +/// The state we need to keep around only until we enter the IO +/// thread. Then we can throw it all away. +struct ThreadEnterState { + /// Initial input to send to the subprocess after starting. + input: Vec, +} + +impl ThreadEnterState { + fn create(config: &Config) -> Option { + // If we have no input then we have no thread enter state + if config.input.list.is_empty() { + return None; + } + + Some(ThreadEnterState { + input: config.input.clone_parsed().list, + }) + } + + /// Prepare the inputs for use: raw strings are used as is, files are read + /// (up to 10 MiB each). + fn prepare_input(&self) -> Result>, Error> { + let mut out = Vec::with_capacity(self.input.len()); + for item in &self.input { + match item { + ReadableIO::Raw(v) => out.push(v.clone().into_bytes()), + ReadableIO::Path(path) => { + let f = std::fs::File::open(path).map_err(|err| { + log::warn!("failed to open input file={path} err={err}"); + Error::InputNotFound + })?; + let mut buf = Vec::new(); + // 10 MiB max + let limit = 10 * 1024 * 1024; + f.take(limit + 1).read_to_end(&mut buf).map_err(|err| { + log::warn!("failed to read input file err={err}"); + Error::InputFailed + })?; + if buf.len() as u64 > limit { + log::warn!("failed to read input file err=StreamTooLong"); + return Err(Error::InputFailed); + } + out.push(buf); + } + } + } + Ok(out) + } +} + +/// What the stream needs mutual exclusion for (under the terminal lock). +struct OutputInner { + stream: TerminalStream, + /// Last time the cursor was reset. This is used to prevent message + /// flooding with cursor resets. + last_cursor_reset: Option, +} + +/// The part of termio used from the pty read thread: parses the pty output +/// and applies it to the shared terminal. +pub struct Output { + state: Arc, + inner: Mutex, + renderer_mailbox: Arc, + mailbox: Mailbox, + surface_mailbox: Arc, +} + +impl Output { + fn inner(&self) -> MutexGuard<'_, OutputInner> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Process output from the pty. This is the manual API that users can + /// call with pty data but it is also called by the read thread when using + /// an exec subprocess. + pub fn process_output(&self, buf: &[u8]) { + // We are modifying terminal state from here on out and we need + // the lock to grab our read data. + let outbox = { + let mut term = self.state.lock(); + let mut inner = self.inner(); + self.process_output_locked(&mut term, &mut inner, buf); + + // If our stream handling caused messages to be sent to the + // mailbox thread, then we need to wake it up so that it + // processes them. + if inner.stream.state.take_termio_messaged() { + self.mailbox.notify(); + } + inner.stream.state.take_outbox() + }; + + // Anything that didn't fit in a full mailbox is delivered now that + // the locks are released. + if !outbox.is_empty() { + outbox.deliver(&self.mailbox, &*self.surface_mailbox); + } + } + + /// Process output from the pty but the lock is already held. + fn process_output_locked(&self, term: &mut Terminal, inner: &mut OutputInner, buf: &[u8]) { + // Schedule a render. We can call this first because we have the lock. + inner.stream.state.queue_render(); + + // Whenever a character is typed, we ensure the cursor is in the + // non-blink state so it is rendered if visible. If we're under + // HEAVY read load, we don't want to send a ton of these so we + // use a timer under the covers + let now = Instant::now(); + let reset = match inner.last_cursor_reset { + Some(last) => now.duration_since(last) > Duration::from_millis(500), + None => true, + }; + if reset { + inner.last_cursor_reset = Some(now); + self.renderer_mailbox + .push(RendererMessage::ResetCursorBlink, Timeout::Instant); + } + + if let Err(err) = inner.stream.process(term, buf) { + log::error!("error processing terminal data: {err:?}"); + } + } +} + +/// See the module docs. +pub struct Termio { + /// This is the implementation responsible for io. + backend: Backend, + + /// The derived configuration for this termio implementation. + config: DerivedConfig, + + /// The shared render state (contains the terminal). + renderer_state: Arc, + + /// The part shared with the pty read thread. + output: Arc, + + /// A handle to wake up the renderer. This hints to the renderer that that + /// a repaint should happen. + renderer_wakeup: Wakeup, + + /// The mailbox for notifying the renderer of things. + renderer_mailbox: Arc, + + /// The mailbox for communicating with the surface. + surface_mailbox: Arc, + + /// The cached size info + size: Size, + + /// The mailbox implementation to use. + mailbox: Mailbox, + + /// State we have for thread enter. This may be None if we don't need + /// to keep track of any state or if its already been freed. + thread_enter_state: Option, +} + +/// ThreadData is the data created and stored in the termio thread +/// when the thread is started and destroyed when the thread is +/// stopped. +/// +/// All of the fields in this struct should only be read/written by +/// the termio thread. As such, a lock is not necessary. +pub struct ThreadData { + /// The shared render state + pub renderer_state: Arc, + + /// Mailboxes for different threads + pub surface_mailbox: Arc, + + /// Data associated with the backend implementation (i.e. pty/exec state) + pub backend: backend::ThreadData, + pub mailbox: Mailbox, +} + +impl Termio { + /// Initialize the termio state. + /// + /// This does NOT start the child process: that happens in + /// [`Termio::thread_enter`] on the IO thread. + pub fn init(opts: Options<'_>) -> Result { + let Options { + size, + full_config, + config, + mut backend, + mailbox, + renderer_wakeup, + renderer_mailbox, + surface_mailbox, + } = opts; + + // The default terminal modes based on our config. + let default_modes: ModePacked = { + let mut modes = ModePacked::default(); + + // Setup our initial grapheme cluster support if enabled. + match full_config.grapheme_width_method { + GraphemeWidthMethod::Unicode => modes.set(Mode::GraphemeCluster, true), + GraphemeWidthMethod::Legacy => {} + } + + // Set default cursor blink settings + modes.set(Mode::CursorBlinking, config.cursor_blink.unwrap_or(true)); + + modes + }; + + // Create our terminal + let grid_size = size.grid(); + let mut term = Terminal::new(TerminalOptions { + cols: grid_size.columns, + rows: grid_size.rows, + max_scrollback: full_config.scrollback_limit, + default_modes, + colors: Colors { + background: DynamicRgb::init(config.background.to_terminal_rgb()), + foreground: DynamicRgb::init(config.foreground.to_terminal_rgb()), + cursor: config + .cursor_color + .and_then(TerminalColor::to_terminal_rgb) + .map_or(DynamicRgb::UNSET, DynamicRgb::init), + palette: DynamicPalette::init(config.palette), + }, + })?; + + // Set the image size limits + // TODO(integration): `screen.kitty_images.setLimit(config.image_storage_limit)` + // once ghostty-terminal ports kitty `ImageStorage` (currently a stub). + + // Set our default cursor style + term.screen_mut().cursor.cursor_style = cursor_style_from_config(config.cursor_style); + + // Setup our terminal size in pixels for certain requests. + term.width_px = u32::from(term.cols) * size.cell.width; + term.height_px = u32::from(term.rows) * size.cell.height; + + // Setup our backend. + backend.init_terminal(&mut term); + + // Create our stream handler. + let handler = HandlerState::new(HandlerOptions { + size, + termio_mailbox: mailbox.clone(), + surface_mailbox: Arc::clone(&surface_mailbox), + renderer_wakeup: renderer_wakeup.clone(), + default_cursor_style: cursor_style_from_config(config.cursor_style), + default_cursor_blink: config.cursor_blink, + enquiry_response: config.enquiry_response.clone().into_bytes(), + osc_color_report_format: config.osc_color_report_format, + clipboard_write: config.clipboard_write, + }); + + let thread_enter_state = ThreadEnterState::create(full_config); + + let renderer_state = Arc::new(RendererState::new(term)); + let output = Arc::new(Output { + state: Arc::clone(&renderer_state), + inner: Mutex::new(OutputInner { + stream: TerminalStream::new(handler), + last_cursor_reset: None, + }), + renderer_mailbox: Arc::clone(&renderer_mailbox), + mailbox: mailbox.clone(), + surface_mailbox: Arc::clone(&surface_mailbox), + }); + + Ok(Termio { + backend, + config, + renderer_state, + output, + renderer_wakeup, + renderer_mailbox, + surface_mailbox, + size, + mailbox, + thread_enter_state, + }) + } + + /// The shared render state (it holds the terminal). Hand this to the + /// renderer. + pub fn renderer_state(&self) -> &Arc { + &self.renderer_state + } + + /// The termio mailbox (clone it to send messages to the IO thread). + pub fn mailbox(&self) -> &Mailbox { + &self.mailbox + } + + /// The part of termio shared with the pty read thread; also the manual + /// API to feed output (`process_output`). + pub fn output(&self) -> &Arc { + &self.output + } + + /// The surface mailbox. + pub fn surface_mailbox(&self) -> &Arc { + &self.surface_mailbox + } + + /// The derived config currently in effect. + pub fn config(&self) -> &DerivedConfig { + &self.config + } + + /// The current size. + pub fn size(&self) -> Size { + self.size + } + + /// The renderer wakeup handle. + pub fn renderer_wakeup(&self) -> &Wakeup { + &self.renderer_wakeup + } + + pub fn thread_enter(&mut self) -> Result { + // Always free our thread enter state when we're done. + let enter_state = self.thread_enter_state.take(); + + // If we have thread enter state then we're going to validate + // and set that all up now so that we can error before we actually + // start the command and pty. + let inputs: Vec> = match &enter_state { + Some(v) => v.prepare_input()?, + None => Vec::new(), + }; + + // Setup our backend + let backend = self.backend.thread_enter( + Arc::clone(&self.output), + Arc::clone(&self.surface_mailbox), + Arc::clone(&self.renderer_state), + )?; + let mut data = ThreadData { + renderer_state: Arc::clone(&self.renderer_state), + surface_mailbox: Arc::clone(&self.surface_mailbox), + backend, + mailbox: self.mailbox.clone(), + }; + + // If we have inputs, then queue them all up. + for input in inputs { + self.queue_write(&mut data, &input, false); + } + + Ok(data) + } + + pub fn thread_exit(&mut self, data: &mut ThreadData) { + self.backend.thread_exit(&mut data.backend); + } + + /// Send a message to the the mailbox and notify the IO thread. + /// + /// Do not call this while holding the terminal lock if the mailbox may + /// be full; use `Mailbox::send_locked` + `Mailbox::notify` in that case. + pub fn queue_message(&self, msg: Message) { + self.mailbox.send(msg); + self.mailbox.notify(); + } + + /// Queue a write directly to the pty. + /// + /// If you're using `Thread`, this must ONLY be called from the mailbox + /// thread. If you're not on the thread, use `queue_message` with + /// mailbox messages instead. + pub fn queue_write(&mut self, td: &mut ThreadData, data: &[u8], linefeed: bool) { + self.backend.queue_write(&mut td.backend, data, linefeed); + } + + /// Update the configuration. + pub fn change_config(&mut self, td: &mut ThreadData, config: DerivedConfig) { + // The remainder of this function is modifying terminal state or + // the read thread data, all of which requires holding the renderer + // state lock. + let mut term = self.renderer_state.lock(); + + // Update our stream handler. The stream handler uses the same + // renderer mutex so this is safe to do despite being executed + // from another thread. + self.output.inner().stream.change_config(&mut term, &config); + td.backend.change_config(&config); + + // Update the configuration that we know about. + // + // Specific things we don't update: + // - command, working-directory: we never restart the underlying + // process so we don't care or need to know about these. + + // Update the default palette. + term.colors.palette.change_default(config.palette); + term.flags.dirty.palette = true; + + // Update all our other colors + term.colors.background.default = Some(config.background.to_terminal_rgb()); + term.colors.foreground.default = Some(config.foreground.to_terminal_rgb()); + term.colors.cursor.default = config.cursor_color.and_then(TerminalColor::to_terminal_rgb); + + // Set the image size limits + // TODO(integration): kitty_images.setLimit (see `init`). + + self.config = config; + } + + /// Resize the terminal. + pub fn resize(&mut self, td: &mut ThreadData, size: Size) -> Result<(), Error> { + self.size = size; + let grid_size = size.grid(); + + // Update the size of our pty. + self.backend.resize(grid_size, size.terminal())?; + + // Enter the critical area that we want to keep small + { + let state = Arc::clone(&self.renderer_state); + let mut term = state.lock(); + self.output().inner().stream.state.size = size; + + // Update the size of our terminal state + term.resize(grid_size.columns, grid_size.rows)?; + + // Update our pixel sizes + term.width_px = u32::from(grid_size.columns) * size.cell.width; + term.height_px = u32::from(grid_size.rows) * size.cell.height; + + // Disable synchronized output mode so that we show changes + // immediately for a resize. This is allowed by the spec. + term.modes.set(Mode::SynchronizedOutput, false); + + // If we have size reporting enabled we need to send a report. + if term.modes.get(Mode::InBandSizeReports) { + self.size_report_locked(td, SizeReport::Mode2048); + } + } + + // Mail the renderer so that it can update the GPU and re-render + self.renderer_mailbox + .push(RendererMessage::Resize(size), Timeout::Forever); + if let Err(err) = self.renderer_wakeup.notify() { + log::warn!("failed to notify renderer err={err}"); + } + Ok(()) + } + + /// Make a size report. + pub fn size_report(&mut self, td: &mut ThreadData, style: SizeReport) { + let state = Arc::clone(&self.renderer_state); + let _term = state.lock(); + self.size_report_locked(td, style); + } + + fn size_report_locked(&mut self, td: &mut ThreadData, style: SizeReport) { + let grid_size = self.size.grid(); + let cell = self.size.cell; + let columns = u32::from(grid_size.columns); + let rows = u32::from(grid_size.rows); + + let message = match style { + SizeReport::Mode2048 => format!( + "\x1B[48;{};{};{};{}t", + rows, + columns, + rows * cell.height, + columns * cell.width, + ), + SizeReport::Csi14T => { + format!("\x1b[4;{};{}t", rows * cell.height, columns * cell.width) + } + SizeReport::Csi16T => format!("\x1b[6;{};{}t", cell.height, cell.width), + SizeReport::Csi18T => format!("\x1b[8;{rows};{columns}t"), + }; + + self.queue_write(td, message.as_bytes(), false); + } + + /// Reset the synchronized output mode. This is usually called by timer + /// expiration from the termio thread. + pub fn reset_synchronized_output(&mut self) { + let mut term = self.renderer_state.lock(); + term.modes.set(Mode::SynchronizedOutput, false); + if let Err(err) = self.renderer_wakeup.notify() { + log::warn!("failed to notify renderer err={err}"); + } + } + + /// Clear the screen. + pub fn clear_screen(&mut self, td: &mut ThreadData, history: bool) { + { + let mut term = self.renderer_state.lock(); + + // If we're on the alternate screen, we do not clear. Since this is an + // emulator-level screen clear, this messes up the running programs + // knowledge of where the cursor is and causes rendering issues. So, + // for alt screen, we do nothing. + if term.screens.active_key == ScreenKey::Alternate { + return; + } + + // Clear our selection + term.screen_mut().clear_selection(); + + // Clear our scrollback + if history { + term.erase_display(csi::EraseDisplay::Scrollback, false); + } + + // If we're not at a prompt, we just delete above the cursor. + if !term.cursor_is_at_prompt() { + let y = term.screen().cursor.y; + if y > 0 { + term.screen_mut().erase_rows( + Point::Active(Coordinate { x: 0, y: 0 }), + Some(Point::Active(Coordinate { + x: 0, + y: u32::from(y) - 1, + })), + ); + } + + // Clear all Kitty graphics state for this screen. This copies + // Kitty's behavior when Cmd+K deletes all Kitty graphics. I + // didn't spend time researching whether it only deletes Kitty + // graphics that are placed above the cursor or if it deletes + // all of them. We delete all of them for now but if this behavior + // isn't fully correct we should fix this later. + // TODO(integration): `kitty_images.delete(.{ .all = true })` + // once kitty image storage is ported; only mark dirty for now. + term.screen_mut().kitty_images.dirty = true; + + return; + } + + // At a prompt, we want to first fully clear the screen, and then after + // send a FF (0x0C) to the shell so that it can repaint the screen. + term.erase_display(csi::EraseDisplay::Complete, false); + } + + // If we reached here it means we're at a prompt, so we send a form-feed. + self.queue_write(td, &[0x0C], false); + } + + /// Scroll the viewport + pub fn scroll_viewport(&mut self, scroll: ScrollViewport) { + self.renderer_state.lock().scroll_viewport(scroll); + } + + /// Jump the viewport to the prompt. + pub fn jump_to_prompt(&mut self, delta: isize) { + { + let mut term = self.renderer_state.lock(); + term.screen_mut().scroll(Scroll::DeltaPrompt(delta)); + } + + if let Err(err) = self.renderer_wakeup.notify() { + log::warn!("failed to notify renderer err={err}"); + } + } + + /// Called when focus is gained or lost (when focus events are enabled) + pub fn focus_gained(&mut self, td: &mut ThreadData, focused: bool) { + let focus_event = self.renderer_state.lock().modes.get(Mode::FocusEvent); + + // If we have focus events enabled, we send the focus event. + if focus_event { + let seq: &[u8] = if focused { b"\x1b[I" } else { b"\x1b[O" }; + self.queue_write(td, seq, false); + } + + // We always notify our backend of focus changes. + self.backend.focus_gained(&mut td.backend, focused); + } + + /// Process output from the pty. See [`Output::process_output`]. + pub fn process_output(&self, buf: &[u8]) { + self.output.process_output(buf); + } + + /// Sends a DSR response for the current color scheme to the pty. + pub fn color_scheme_report(&mut self, td: &mut ThreadData, force: bool) { + let state = Arc::clone(&self.renderer_state); + let term = state.lock(); + self.color_scheme_report_locked(&term, td, force); + } + + pub fn color_scheme_report_locked( + &mut self, + term: &Terminal, + td: &mut ThreadData, + force: bool, + ) { + if !force && !term.modes.get(Mode::ReportColorScheme) { + return; + } + let output: &[u8] = match self.config.conditional_state.theme { + conditional::Theme::Light => b"\x1B[?997;2n", + conditional::Theme::Dark => b"\x1B[?997;1n", + }; + self.queue_write(td, output, false); + } +} diff --git a/crates/ghostty-termio/src/thread.rs b/crates/ghostty-termio/src/thread.rs new file mode 100644 index 0000000..710b3d8 --- /dev/null +++ b/crates/ghostty-termio/src/thread.rs @@ -0,0 +1,421 @@ +//! 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::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, + + /// This is used to coalesce resize events. + coalesce_deadline: Option, + coalesce_resize: Option, + + /// This timer is used to reset synchronized output modes so that + /// the terminal doesn't freeze with a bad actor. + sync_reset_deadline: Option, + + 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 { + 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 { + [ + 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> = 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 = 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(); + + // 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); +} diff --git a/crates/ghostty-termio/src/wakeup.rs b/crates/ghostty-termio/src/wakeup.rs new file mode 100644 index 0000000..5110081 --- /dev/null +++ b/crates/ghostty-termio/src/wakeup.rs @@ -0,0 +1,96 @@ +//! A tiny cross-thread wakeup primitive (replaces `xev.Async`). +//! +//! Backed by a non-blocking `eventfd`, so it can be included in a `poll(2)` +//! set next to a pty fd or a pidfd. [`Wakeup::notify`] may be called from any +//! thread, any number of times; the waiter calls [`Wakeup::drain`] after +//! `poll` reports the fd readable. + +use std::io; +use std::os::fd::{AsFd, BorrowedFd, OwnedFd}; +use std::sync::Arc; + +use rustix::event::{EventfdFlags, eventfd}; + +/// See the module docs. Cloning shares the same underlying eventfd. +#[derive(Clone, Debug)] +pub struct Wakeup(Arc); + +impl Wakeup { + pub fn new() -> io::Result { + let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?; + Ok(Wakeup(Arc::new(fd))) + } + + /// Wake up the waiter. Multiple notifications coalesce. + pub fn notify(&self) -> io::Result<()> { + loop { + match rustix::io::write(&*self.0, &1u64.to_ne_bytes()) { + Ok(_) => return Ok(()), + Err(rustix::io::Errno::INTR) => {} + // Counter would overflow: the waiter is already guaranteed + // to wake up. + Err(rustix::io::Errno::AGAIN) => return Ok(()), + Err(e) => return Err(e.into()), + } + } + } + + /// Reset the notification state. Returns true if a notification was + /// pending. + pub fn drain(&self) -> bool { + let mut buf = [0u8; 8]; + loop { + match rustix::io::read(&*self.0, &mut buf) { + Ok(_) => return true, + Err(rustix::io::Errno::INTR) => {} + Err(_) => return false, + } + } + } + + /// The fd to poll for `POLLIN`. + pub fn as_fd(&self) -> BorrowedFd<'_> { + self.0.as_fd() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustix::event::{PollFd, PollFlags, poll}; + + fn readable(w: &Wakeup) -> bool { + let mut fds = [PollFd::from_borrowed_fd(w.as_fd(), PollFlags::IN)]; + poll( + &mut fds, + Some(&rustix::time::Timespec { + tv_sec: 0, + tv_nsec: 0, + }), + ) + .unwrap() + > 0 + } + + #[test] + fn notify_coalesces_and_drains() { + let w = Wakeup::new().unwrap(); + assert!(!readable(&w)); + w.notify().unwrap(); + w.notify().unwrap(); + assert!(readable(&w)); + assert!(w.drain()); + assert!(!readable(&w)); + assert!(!w.drain()); + } + + #[test] + fn notify_from_other_thread() { + let w = Wakeup::new().unwrap(); + let w2 = w.clone(); + std::thread::spawn(move || w2.notify().unwrap()) + .join() + .unwrap(); + assert!(readable(&w)); + } +} diff --git a/crates/ghostty-termio/tests/exec_integration.rs b/crates/ghostty-termio/tests/exec_integration.rs new file mode 100644 index 0000000..22da94f --- /dev/null +++ b/crates/ghostty-termio/tests/exec_integration.rs @@ -0,0 +1,579 @@ +#![allow(clippy::unwrap_used)] +//! 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`. + +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use crossbeam_channel::{Receiver, Sender, unbounded}; +use ghostty_apprt::surface::Message as SurfaceMessage; +use ghostty_config::command::Command as ConfigCommand; +use ghostty_config::config::Config; +use ghostty_config::io::ReadableIO; +use ghostty_config::types_b::{LinuxCgroup, ShellIntegration}; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_renderer::message::Message as RendererMessage; +use ghostty_renderer::size::{CellSize, Padding, ScreenSize, Size}; +use ghostty_termio::backend::{self, Backend}; +use ghostty_termio::exec; +use ghostty_termio::message::Message; +use ghostty_termio::termio::DerivedConfig; +use ghostty_termio::{ + Mailbox, Options, RendererMailbox, RendererState, SurfaceMailbox, Termio, Thread, Wakeup, +}; + +struct SurfaceRec(Sender); + +impl SurfaceMailbox for SurfaceRec { + fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize { + let _ = self.0.send(msg); + 1 + } +} + +#[derive(Default)] +struct RendererRec { + resizes: Mutex>, + 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: Arc, + mailbox: Mailbox, + stop: Wakeup, + thread: Option>, + surface: Receiver, + renderer: Arc, + 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(), "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().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(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option) -> 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) { + 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(); + 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; head -c 262144 | wc -c; echo done"), + |_| {}, + ); + 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().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("/nonexistent/ghostty-input".into())); + }); + f.wait_text("A configured `input` path was not found"); + assert!( + !f.state + .lock() + .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(); + 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() + .modes + .get(ghostty_vt::modes::Mode::SynchronizedOutput) + .then_some(()) + }); + let started = Instant::now(); + f.wait_for("sync output reset", |f| { + (!f.state + .lock() + .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() + .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().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(); + (t.colors.background.default == Some(ghostty_vt::color::Rgb { r: 1, g: 2, b: 3 })) + .then_some(()) + }); + assert!(f.state.lock().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(std::collections::HashMap::new(), "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().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")); +} diff --git a/docs/rewrite/notes/ghostty-termio-IO2.md b/docs/rewrite/notes/ghostty-termio-IO2.md new file mode 100644 index 0000000..7b0ac65 --- /dev/null +++ b/docs/rewrite/notes/ghostty-termio-IO2.md @@ -0,0 +1,116 @@ +# ghostty-termio notes (agent IO2): Termio, Thread, Exec, stream handler + +Builds on `ghostty-termio-IO1.md` (pty, Command, shell_integration, terminfo). Linux only. +Deps added: ghostty-config, ghostty-terminal, ghostty-vt, ghostty-renderer, ghostty-apprt +(path dep; it is not in `[workspace.dependencies]`), ghostty-datastruct, crossbeam-channel, +rustix features `event`,`pipe`. + +## File -> module +| Zig | Rust | +|---|---| +| Options.zig | `options.rs` (`Options`) | +| backend.zig | `backend.rs` (`Backend`, `ThreadData`, `Config`, `Kind`) | +| message.zig | `message.rs` (`Message`, `SizeReport`, `WriteReq`) | +| mailbox.zig | `mailbox.rs` (`Mailbox`, bounded crossbeam queue of 64 + eventfd) | +| Termio.zig | `termio.rs` (`Termio`, `Output`, `DerivedConfig`, `ThreadData`) | +| Thread.zig | `thread.rs` (`Thread`) | +| Exec.zig | `exec.rs` (`Exec`, `Config`, `ThreadData`, read thread, Subprocess, `exec_command`) | +| stream_handler.zig | `stream_handler.rs` (`StreamHandler`, `TerminalStream`, `HandlerState`) | +| (xev.Async) | `wakeup.rs` (`Wakeup`, eventfd) | +| (renderer.State, apprt/renderer mailboxes) | `state.rs` (`RendererState`), `sinks.rs` (`SurfaceMailbox`, `RendererMailbox`) | +IO1 stand-ins replaced by real types: `shell_integration::{Command, ShellIntegrationFeatures}` are +re-exports of `ghostty_config::{command::Command, types_b::ShellIntegrationFeatures}`; +`command::{LinuxCgroup, GtkSingleInstance}` re-export `ghostty_config::types_b`; `RtPreExecInfo::init(&Config)` +/ `RtPostForkInfo::init(&Config)` added. NOTE config enum `Default` is the first variant; our info structs keep +`Default` = `single-instance` / `detect` (the config defaults). `Detect` must be resolved by the apprt before +spawning (as in Zig GTK) or the child exits 127 in `linux_cgroup_pre_exec`; tests use `LinuxCgroup::Never`. + +## Threading model +Per surface: (1) the **IO thread** (`Thread::thread_main(termio)`) owns `Termio`; (2) the **pty read thread** +("io-reader", spawned in `Exec::thread_enter`) reads the pty and calls `Output::process_output`; (3) the renderer +thread and (4) the surface/core thread are outside this crate. +* libxev -> one `poll(2)` loop in `Thread::run`: mailbox eventfd, stop eventfd, child pidfd (fallback: 50ms + `waitpid(WNOHANG)` poll; Flatpak: channel+eventfd), pty `POLLOUT` (only while writes are queued) and deadlines + (selection-scroll 15ms, resize coalesce 25ms, sync-output reset 1000ms, termios poll 200ms). +* The `Terminal` lives in `Arc` (`Mutex`), shared with the renderer like Zig's + `renderer.State`. Lock order: terminal mutex, then the stream mutex inside `Output`. +* Mailbox full: Zig unlocks the renderer mutex mid-handler and blocks. Impossible with an owned guard, so + `HandlerState` keeps an in-order *outbox*; `Output::process_output` delivers it (blocking) after dropping the + locks. `Mailbox::send_locked(msg, &mutex, guard)` gives other callers the Zig unlock/relock behaviour. +* Writes: `ThreadData` keeps a `VecDeque>`, written non-blocking; `POLLOUT` polled on `EAGAIN`. + Backpressure to producers is the 64-slot mailbox. Linefeed mode replaces `\r` by `\r\n`. +* Stop: notify the handle from `Thread::stop_handle()`; `Exec::thread_exit` kills the process group (SIGHUP loop + with `waitpid(WNOHANG)`, unless the exit watcher already reaped it), writes the quit pipe and joins the reader. +* Start failure (`pty` open, input file, spawn): the message is printed on the terminal (cursor hidden) and the + thread keeps draining the mailbox (dropping messages) until stopped, like Zig. + +## API for the core Surface agent +```rust +let mailbox = Mailbox::new()?; // clone: producers (surface) + consumer (IO thread) +let mut ecfg = exec::Config::new(env_map, config.term.clone()); // command, env_override, shell_integration(+features), +ecfg.command = config.command.clone(); /* working_directory, resources_dir, cursor_blink, rt_*_info, create_scope */ +let termio = Termio::init(Options { + size, // renderer::size::Size (screen, cell, padding) + full_config: &config, // only read during init (scrollback, grapheme width, input) + config: DerivedConfig::new(&config), + backend: Backend::new(backend::Config::Exec(ecfg))?, + mailbox: mailbox.clone(), + renderer_wakeup, // Wakeup the renderer thread polls (termio notifies it after output/messages) + renderer_mailbox: Arc, // push(renderer::Message, Timeout): gets Resize, ResetCursorBlink + surface_mailbox: Arc, // blanket impl for apprt::surface::Mailbox +})?; +let state: Arc = termio.renderer_state().clone(); // hand to the renderer: state.lock() -> Terminal +let thread = Thread::new()?; let stop = thread.stop_handle(); +let join = std::thread::Builder::new().name("io".into()).spawn(move || thread.thread_main(termio))?; +// later: mailbox.send(Message::...); mailbox.notify(); (or termio.queue_message before moving it) +// shutdown: stop.notify(); join.join(); +``` +Messages the IO thread accepts (`message::Message`): `ColorSchemeReport{force}`, `Crash`, `ChangeConfig(Box)`, +`Inspector(bool)` (flag only), `Resize(Size)` (coalesced 25ms), `SizeReport(..)`, `ClearScreen{history}`, +`ScrollViewport`, `SelectionScroll(bool)`, `JumpToPrompt(isize)`, `StartSynchronizedOutput`, `LinefeedMode(bool)`, +`Focused(bool)`, `Write(WriteReq)` (`Message::write_req(&[u8])`, `Message::write_stable(&'static [u8])`). +Surface messages sent (apprt `surface::Message`): `ChildExited{exit_code, runtime_ms}` (exit code; signal number if +killed), `PasswordInput(bool)` (termios poll, only while focused), `SelectionScrollTick(bool)`, `SetTitle`, `ReportTitle`, +`SetMouseShape`, `ClipboardRead/Write`, `PwdChange`, `ColorChange`, `DesktopNotification`, `ProgressReport`, +`RingBell`, `StartCommand`, `StopCommand(Some(code))`. +Renderer messages sent: `Resize(Size)` (Forever) and `ResetCursorBlink` (Instant, at most every 500ms); plus +`renderer_wakeup.notify()` after output, mailbox drains and resets. +**wait-after-command and abnormal-exit detection are NOT in termio** (Zig `Surface.zig`): the surface compares +`runtime_ms` with `abnormal-command-exit-runtime` and decides whether to close; the IO thread simply keeps running +(writes ignored, resize/focus still processed) until stopped. `Backend.childExitedAbnormally` in backend.zig has no +Exec implementation upstream and is not ported. +Manual use without a child: `Termio::process_output(bytes)` / `Output::process_output`. + +## Tests +Unit 102 total in the crate (IO1: 47 incl. updated), IO2 new: wakeup 2, mailbox 4, message 2, exec 4, +stream_handler 41 (DA1/DA2/DSR/CPR/origin CPR, XTVERSION, ENQ, DECRQM, kitty kbd query, OSC 4/10/11/12 queries in +16/8-bit/none, OSC 21, XTGETTCAP, DECRQSS sgr/decscusr/decstbm/decslrm/invalid, size reports, title, OSC 7 local/remote/reset, +OSC 52, notifications, progress, OSC 133, cursor-style default/blink override, mode side effects, outbox ordering, ...). +Integration `tests/exec_integration.rs` (21): real `sh -c 'printf hello\e[31mred'` (contents, SGR, exit code 0), +exit code, signal exit, exec failure, echo/cat writes, 256 KiB write backpressure, linefeed mode, query reply round +trip (DA1, CSI 18 t), initial `input`, missing input file error screen + drain, resize coalescing -> pty size, +sync-output timer reset, password-input detection, selection-scroll timer, focus events, clear-screen, stop kills +process group, writes after exit, ChangeConfig, manual `process_output`. +Zig tests: message.zig 2 (`refAllDecls` skipped; size test replaced by a growth guard), Exec.zig execCommand 7 -> +Linux subset ported (shell command, direct command, config freed; passwd variants collapse because the passwd +argument only mattered on macOS; 2 darwin tests skipped). Zig has no tests in Termio/Thread/stream_handler/mailbox. + +## Deviations / TODO(integration) +* `RendererState` is defined here (renderer `State.zig` deferred); no `inspector`/`preedit`/`mouse`. Inspector + slow-path (byte-at-a-time) is not ported; `TerminalStream::process_byte` exists for it. +* kitty image storage (`set_limit`, `delete`) is a stub in ghostty-terminal: `image_storage_limit` is not applied; + clear-screen only marks kitty images dirty. `Terminal::kitty_graphics` returns None, so no APC replies yet. +* tmux control mode DCS: parsed by ghostty-vt but ignored here (`terminal.tmux.Viewer` not ported). +* Message layout differs: three Zig write variants are one `Message::Write(WriteReq)`; `ChangeConfig` owns a boxed + `DerivedConfig` (no allocator); `size_of::() == 40` is replaced by a growth guard. +* Mailbox is a crossbeam bounded channel (the datastruct `BlockingQueue` cannot return the rejected message). +* Process exit code: normal exit -> status, killed by signal -> signal number (libxev behaviour). +* Drain loop continues after a failing message (Zig stopped the drain at the first error). +* Read thread: on `POLLHUP` it does one more drain of the fd before exiting (Zig exits at once and could lose + trailing output). `getpgid` retry loop capped at ~5s (Zig loops forever). +* Flatpak host-command path is ported but untestable here. `FlatpakHostCommand::wait_callback` consumes the command, + so `stop()` cannot signal it afterwards (TODO(ghostty-os): allow `signal` after `wait_callback`). +* `Config.create_scope` (cgroup D-Bus call) and `restore_rlimit` must be supplied by apprt-gtk / core. +* macOS `login(1)` / Windows branches of `execCommand` not ported (Linux only). +* ghostty_terminal `StreamHandler` writes `Terminal` API through `&mut Terminal` borrowed per read; parser state + (`Parser`, `Utf8Decoder`) persists in `TerminalStream`.