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`.