feat(termio): port the IO thread, Exec backend and stream handler

Ports the remainder of ZIG/src/termio: Options, backend, message,
mailbox, Termio, Thread, Exec and stream_handler.

Threading model (replacing libxev): the IO thread owns Termio and runs a
single poll(2) loop over the mailbox eventfd, a stop eventfd, the child
pidfd and pty POLLOUT, driving Ghostty's timers (25 ms resize
coalescing, 1 s synchronized-output, 15 ms selection scroll, 200 ms
termios polling). A separate pty read thread locks the shared renderer
state and feeds the terminal. Renderer and surface interaction go
through RendererMailbox/SurfaceMailbox traits.

The stream handler answers all queries (DA, DSR, XTVERSION, DECRQM,
OSC color queries, XTGETTCAP, DECRQSS, ...) and forwards titles, pwd,
OSC 52, notifications, progress and bell as surface messages. Zig
unlocks the renderer mutex when a mailbox is full; here overflow goes to
an ordered outbox flushed after the locks drop.

IO1's local stand-ins are replaced with the real config types.

Open: RendererState lives in termio for now (renderer State.zig is
being ported); kitty graphics replies and tmux viewer are not hooked up;
wait-after-command lives in Surface (core); Flatpak stop path needs a
ghostty-os change.

Test Plan:
- cargo test -p ghostty-termio (102 unit + 21 end-to-end tests on a
  real pty, incl. backpressure, DA1 replies, resize coalescing, exits)
This commit is contained in:
ddidderr committed 2026-10-11 22:58:48 +02:00
1 parent 0c92dd0966
commit c81375e6f1
19 files changed
+6170 -195

No files matched your search

+1330
View File
@@ -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<dyn Fn(u32, Option<u64>, Option<u64>) -> Option<Result<(), String>> + Send + Sync>;
/// Configuration for the exec backend (`Exec.Config`).
pub struct Config {
pub command: Option<ConfigCommand>,
pub env: EnvMap,
pub env_override: RepeatableStringMap,
pub shell_integration: ShellIntegration,
pub shell_integration_features: ShellIntegrationFeatures,
pub cursor_blink: Option<bool>,
pub working_directory: Option<String>,
pub resources_dir: Option<String>,
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<CreateScopeFn>,
/// The rlimit to restore in the child (Zig: `global_state.rlimits`).
pub restore_rlimit: Option<Rlimit>,
}
impl Config {
/// A config with Zig-struct-style defaults for everything optional.
pub fn new(env: EnvMap, term: impl Into<String>) -> 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<Exec, Error> {
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<Output>,
surface_mailbox: Arc<dyn SurfaceMailbox>,
renderer_state: Arc<RendererState>,
) -> Result<ThreadData, Error> {
// 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<Result<u8, String>>,
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<Vec<u8>>,
write_offset: usize,
watch: ProcessWatch,
flatpak_events: Option<FlatpakEvents>,
/// Reader thread state
read_thread: Option<JoinHandle<()>>,
read_thread_pipe: Option<OwnedFd>,
/// The termios timer: `None` when not armed.
termios_deadline: Option<Instant>,
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<dyn SurfaceMailbox>,
renderer_state: Arc<RendererState>,
}
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<BorrowedFd<'_>> {
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<BorrowedFd<'_>> {
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<Instant> {
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::<usize>() - 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<i32>) {
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<ghostty_os::flatpak::FlatpakHostCommand>,
}
struct Subprocess {
cwd: Option<String>,
env: Option<EnvMap>,
args: Vec<String>,
grid_size: GridSize,
screen_size: ScreenSize,
pty: Option<Pty>,
process: Option<Process>,
rt_pre_exec_info: RtPreExecInfo,
rt_post_fork_info: RtPostForkInfo,
create_scope: Option<CreateScopeFn>,
restore_rlimit: Option<Rlimit>,
}
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<Subprocess, Error> {
// 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<Shell> = 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::<Vec<_>>()
.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<String> = 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<Pid> {
// 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<String> {
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<String> = 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);
}
}