Files
ghostty-rs/crates/ghostty-termio/src/exec.rs
T
ddidderr b9ad595ca1 fix-deviant(termio): stop waiting for a child that died before setsid
Intentional deviation from Ghostty 1.3.1 (DEVIANT_FIXES.md row 37).
On shutdown Zig's termio/Exec.zig:1188-1224 (getpgid) loops until the
child's process group differs from ours, i.e. until the child has
called setsid. If the child exits before ever calling setsid and is not
yet reaped, its pgid stays ours forever and Zig spins indefinitely.

The Rust port previously capped the wait at ~5 s (which also differed
from Zig for slow children). It now waits without a cap like Zig, but
stops as soon as waitid(WNOWAIT) shows the child has exited.

Test Plan:
- cargo test -p ghostty-termio getpgid_waits_for_late_setsid
  getpgid_gives_up_on_exited_child_without_setsid
2026-10-12 17:23:38 +02:00

1803 lines
66 KiB
Rust

//! 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,
io,
os::fd::{AsFd, BorrowedFd, OwnedFd},
sync::Arc,
thread::JoinHandle,
time::{Duration, Instant},
};
use crossbeam_channel::Receiver;
use ghostty_apprt::surface::{ChildExited, Message as SurfaceMessage};
use ghostty_config::{
command::Command as ConfigCommand,
repeatable_string_map::RepeatableStringMap,
types_b::{ShellIntegration, ShellIntegrationFeatures},
};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_os::{file::Rlimit, osbytes::EncodedOs};
use ghostty_renderer::size::{GridSize, ScreenSize};
use ghostty_terminal::terminal::Terminal;
use rustix::{
event::{PollFd, PollFlags},
process::{Pid, Signal, WaitOptions},
};
use crate::{
command::{
self,
Command,
Exit,
PostForkError,
RtPostForkInfo,
RtPreExecInfo,
linux_cgroup_post_fork,
linux_cgroup_pre_exec,
},
pty::{self, Mode, Pty, Winsize},
shell_integration::{self, EnvMap, Shell},
sinks::SurfaceMailbox,
state::{SharedState, SharedStateExt},
termio::Output,
wakeup::Wakeup,
};
/// 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<EncodedOs>,
pub resources_dir: Option<EncodedOs>,
pub term: EncodedOs,
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: EncodedOs) -> 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,
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<&EncodedOs> {
self.subprocess.cwd.as_ref()
}
/// 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.to_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: SharedState,
) -> 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() {
// Keep a signal handle so stop() can still SIGHUP the
// host process group after the watcher owns the command.
fp.signal = Some(cmd.signal_handle());
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
// The read thread shares our crash context (surface dimensions).
let crash_dims = ghostty_crash::current_dims();
let read_thread =
std::thread::Builder::new()
.name("io-reader".into())
.spawn(move || {
// Setup our crash metadata
let _crash =
ghostty_crash::ThreadGuard::new(ghostty_crash::ThreadType::Io, crash_dims);
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 (Linux only; no
/// equivalent is used on Darwin where libxev uses kqueue, so we poll).
#[cfg(any(target_os = "linux", target_os = "android"))]
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;
};
#[cfg(any(target_os = "linux", target_os = "android"))]
match rustix::process::pidfd_open(p, rustix::process::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))
}
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
let _ = p;
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: SharedState,
}
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 {
#[cfg(any(target_os = "linux", target_os = "android"))]
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 {
#[cfg(any(target_os = "linux", target_os = "android"))]
ProcessWatch::PidFd(_, pid) => *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_terminal();
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).
cmd: Option<ghostty_os::flatpak::FlatpakHostCommand>,
/// Signal handle that outlives the command's move into the watcher.
signal: Option<ghostty_os::flatpak::FlatpakSignalHandle>,
}
struct Subprocess {
cwd: Option<EncodedOs>,
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.to_text_lossy());
env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os());
}
// 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 {
// The configured TERM is an encoded OS string: export its bytes.
env.insert("TERM", cfg.term.to_os());
env.insert("COLORTERM", "truecolor");
// Assume that the resources directory is adjacent to the terminfo
// database
//
// Rust-specific: the in-repo dev resources dir (`<ws>/resources`)
// carries its compiled terminfo inside itself, so prefer that
// when present.
let terminfo = terminfo_dir(base);
env.insert("TERMINFO", terminfo);
} else {
if cfg!(target_vendor = "apple") {
log::warn!("ghostty terminfo not found, using xterm-256color");
log::warn!("the terminfo SHOULD exist on macos, please ensure");
log::warn!("you're using a valid app bundle.");
}
env.insert("TERM", "xterm-256color");
env.insert("COLORTERM", "truecolor");
}
// Add our binary to the path if we can find it.
add_ghostty_path(&mut env);
// On macOS, export additional data directories from our
// application bundle.
if cfg!(target_vendor = "apple")
&& let Some(resources_dir) = &cfg.resources_dir
{
darwin_bundle_env(&mut env, resources_dir);
}
// 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", "ghostty");
env.insert("TERM_PROGRAM_VERSION", VERSION_STRING);
// 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_os(), v.to_os());
}
// 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", pwd.to_os());
}
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<EncodedOs> = 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 proposed.to_path().is_ok_and(|p| p.exists()) {
Some(proposed.clone())
} else {
log::warn!("cannot access cwd, ignoring: {}", proposed.to_text_lossy());
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,
signal: Some(cmd.signal_handle()),
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 gtk = ghostty_config::config::app_runtime() == ghostty_config::config::AppRuntime::Gtk;
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
})),
// Zig installs these only when the apprt declares them
// (`@hasDecl(apprt.runtime, "pre_exec")`): GTK.
rt_pre_exec: gtk.then(|| Box::new(move || linux_cgroup_pre_exec(&pre_info)) as Box<_>),
rt_post_fork: gtk.then(|| {
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)
}) as Box<_>
}),
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) => {
let res = match (&fp.cmd, &fp.signal) {
(Some(cmd), _) => cmd.signal(1 /* SIGHUP */, true),
(None, Some(h)) => h.signal(1 /* SIGHUP */, true),
(None, None) => {
log::warn!("no signal handle for flatpak host command pid={}", fp.pid);
Ok(())
}
};
if let Err(err) = res {
log::error!("error sending SIGHUP to command, may hang: {err}");
}
}
}
}
/// 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;
}
};
add_ghostty_path_for(env, &exe);
}
/// [`add_ghostty_path`] for a given executable (testable with non-UTF-8
/// directories).
fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) {
// Keep the exact bytes of the directory (it may be non-UTF-8).
let Some(exe_dir) = exe.parent().map(|p| p.as_os_str().to_owned()) else {
return;
};
log::debug!("appending ghostty bin to path dir={}", exe_dir.display());
// 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", 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").map(std::ffi::OsStr::to_owned) {
// Verify that our path doesn't already contain this entry
use std::os::unix::ffi::OsStrExt;
if path
.as_bytes()
.split(|&b| b == b':')
.any(|entry| !entry.is_empty() && entry == exe_dir.as_bytes())
{
return;
}
env.insert("PATH", ghostty_os::env::append_env_os(&path, &exe_dir));
} else {
env.insert("PATH", exe_dir);
}
}
/// The `TERMINFO` directory for a resources dir (reversible config-string
/// encoding in, raw OS string out).
fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString {
let base = base.to_os();
let own = std::path::Path::new(&base).join("terminfo");
if own.join("x/xterm-ghostty").exists() {
own.into_os_string()
} else {
let mut parent = std::path::Path::new(&base)
.parent()
.map_or_else(std::ffi::OsString::new, |p| p.as_os_str().to_owned());
parent.push("/terminfo");
parent
}
}
/// macOS: export additional data directories from our application bundle
/// (`XDG_DATA_DIRS` and `MANPATH`).
pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &EncodedOs) {
let xdg_data_dir_key = "XDG_DATA_DIRS";
let mut data_dir = resources_dir.to_os();
data_dir.push("/..");
let current = env.get(xdg_data_dir_key).map_or_else(
|| std::ffi::OsString::from("/usr/local/share:/usr/share"),
std::ffi::OsStr::to_owned,
);
env.insert(
xdg_data_dir_key,
ghostty_os::env::append_env_os(&current, &data_dir),
);
let manpath_key = "MANPATH";
let mut man_dir = resources_dir.to_os();
man_dir.push("/../man");
// Always append with colon in front, as it mean that if
// `MANPATH` is empty, then it should be treated as an extra
// path instead of overriding all paths set by OS.
let current = env
.get(manpath_key)
.map_or_else(std::ffi::OsString::new, std::ffi::OsStr::to_owned);
env.insert(
manpath_key,
ghostty_os::env::append_env_always_os(&current, &man_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()),
// EPERM is expected on Darwin and ignored.
Err(rustix::io::Errno::PERM) if cfg!(target_vendor = "apple") => {
log::debug!("killpg failed with EPERM, expected on Darwin and ignoring");
}
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. Like Zig we wait without a cap, with one
// exception (DEVIANT_FIXES.md): if the child already exited without
// ever creating its own group (still an unreaped zombie, whose pgid
// stays ours forever) Zig would spin here forever.
loop {
match rustix::process::getpgid(Some(pid)) {
Ok(pgid) => {
if Some(pgid) == my_pgid {
if child_exited(pid) {
log::warn!("child exited before creating its process group");
return None;
}
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;
}
}
}
}
/// Whether `pid` has terminated (without reaping it).
fn child_exited(pid: Pid) -> bool {
use rustix::process::{WaitId, WaitIdOptions, waitid};
match waitid(
WaitId::Pid(pid),
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
) {
Ok(Some(_)) => true,
Ok(None) => false,
// Not our child (or already reaped): nothing more to wait for.
Err(_) => true,
}
}
/// 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.
///
/// On macOS the command is wrapped in `login(1)` (see
/// [`exec_command_with`]).
pub fn exec_command(command: &ConfigCommand) -> Vec<String> {
#[cfg(unix)]
let passwd = ghostty_os::passwd::get;
#[cfg(not(unix))]
let passwd = || Err(std::io::Error::from(std::io::ErrorKind::Unsupported));
exec_command_with(command, cfg!(target_vendor = "apple"), &passwd)
}
/// [`exec_command`] with the platform and passwd lookup injected (Zig takes
/// the passwd package as a comptime argument and `isDarwin()` at comptime),
/// so the macOS `login(1)` logic is testable on every platform.
///
/// The `passwd` function is used by macOS to determine the username and
/// home directory for the login shell. It is unused on other platforms.
pub fn exec_command_with(
command: &ConfigCommand,
darwin: bool,
passwd: &dyn Fn() -> std::io::Result<ghostty_os::passwd::Entry>,
) -> Vec<String> {
// If we're on macOS, we have to use `login(1)` to get all of
// the proper environment variables set, a login shell, and proper
// hushlogin behavior.
if darwin && let Some(args) = darwin_login_command(command, passwd) {
return args;
}
match command {
// We need to clone the command since there's no guarantee the config remains valid.
ConfigCommand::Direct(args) => args.iter().map(|v| v.as_encoded().to_owned()).collect(),
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.as_encoded().to_owned());
args
}
}
}
/// The macOS `login(1)` wrapper; `None` falls back to POSIX behavior (Zig
/// `break :darwin`).
fn darwin_login_command(
command: &ConfigCommand,
passwd: &dyn Fn() -> std::io::Result<ghostty_os::passwd::Entry>,
) -> Option<Vec<String>> {
let passwd = match passwd() {
Ok(p) => p,
Err(err) => {
log::warn!("failed to read passwd, not using a login shell err={err}");
return None;
}
};
let Some(username) = passwd.name else {
log::warn!("failed to get username, not using a login shell");
return None;
};
let hush = match &passwd.home {
Some(home) => {
// The passwd home is an encoded OS string: decode it first.
let home = home.to_path().ok()?;
if std::fs::read_dir(&home).is_err() {
log::warn!("failed to open home dir, not checking for hushlogin");
false
} else {
home.join(".hushlogin").exists()
}
}
None => false,
};
// If we made it this far we're going to start building
// the actual command.
//
// This capacity is chosen based on what we'd need to
// execute a shell command (very common). We can/will
// grow if necessary for a longer command (uncommon).
let mut args: Vec<String> = Vec::with_capacity(9);
// The reason for executing login this way is unclear. This
// comment will attempt to explain but prepare for a truly
// unhinged reality.
//
// The first major issue is that on macOS, a lot of users
// put shell configurations in ~/.bash_profile instead of
// ~/.bashrc (or equivalent for another shell). This file is only
// loaded for a login shell so macOS users expect all their terminals
// to be login shells. No other platform behaves this way and its
// totally braindead but somehow the entire dev community on
// macOS has cargo culted their way to this reality so we have to
// do it...
//
// To get a login shell, you COULD just prepend argv0 with a `-`
// but that doesn't fully work because `getlogin()` C API will
// return the wrong value, SHELL won't be set, and various
// other login behaviors that macOS users expect.
//
// The proper way is to use `login(1)`. But login(1) forces
// the working directory to change to the home directory,
// which we may not want. If we specify "-l" then we can avoid
// this behavior but now the shell isn't a login shell.
//
// There is another issue: `login(1)` on macOS 14.3 and earlier
// checked for ".hushlogin" in the working directory. This means
// that if we specify "-l" then we won't get hushlogin honored
// if its in the home directory (which is standard). To get
// around this, we check for hushlogin ourselves and if present
// specify the "-q" flag to login(1).
//
// So to get all the behaviors we want, we specify "-l" but
// execute "bash" (which is built-in to macOS). We then use
// the bash builtin "exec" to replace the process with a login
// shell ("-l" on exec) with the command we really want.
//
// We use "bash" instead of other shells that ship with macOS
// because as of macOS Sonoma, we found with a microbenchmark
// that bash can `exec` into the desired command ~2x faster
// than zsh.
//
// To figure out a lot of this logic I read the login.c
// source code in the OSS distribution Apple provides for
// macOS.
//
// Awesome.
args.push("/usr/bin/login".into());
if hush {
args.push("-q".into());
}
args.push("-flp".into());
args.push(username.into_encoded());
match command {
// Direct args can be passed directly to login, since
// login uses execvp we don't need to worry about PATH
// searching.
ConfigCommand::Direct(v) => args.extend(v.iter().map(|v| v.as_encoded().to_owned())),
ConfigCommand::Shell(v) => {
// Use "exec" to replace the bash process with
// our intended command so we don't have a parent
// process hanging around.
let cmd = format!("exec -l {}", v.as_encoded());
// We execute bash with "--noprofile --norc" so that it doesn't
// load startup files so that (1) our shell integration doesn't
// break and (2) user configuration doesn't mess this process
// up.
args.push("/bin/bash".into());
args.push("--noprofile".into());
args.push("--norc".into());
args.push("-c".into());
args.push(cmd);
}
}
Some(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 ghostty_path_keeps_non_utf8_exe_dir_bytes() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let exe = std::path::PathBuf::from(std::ffi::OsString::from_vec(
b"/opt/gh\xffostty/bin/ghostty".to_vec(),
));
// No PATH: PATH becomes the directory itself.
let mut env = EnvMap::new();
add_ghostty_path_for(&mut env, &exe);
assert_eq!(
env.get("GHOSTTY_BIN_DIR").unwrap().as_bytes(),
b"/opt/gh\xffostty/bin"
);
assert_eq!(env.get("PATH").unwrap().as_bytes(), b"/opt/gh\xffostty/bin");
// Existing PATH: appended byte for byte (no U+FFFD), and not twice.
let mut env = EnvMap::new();
env.insert("PATH", "/usr/bin");
add_ghostty_path_for(&mut env, &exe);
assert_eq!(
env.get("PATH").unwrap().as_bytes(),
b"/usr/bin:/opt/gh\xffostty/bin"
);
add_ghostty_path_for(&mut env, &exe);
assert_eq!(
env.get("PATH").unwrap().as_bytes(),
b"/usr/bin:/opt/gh\xffostty/bin"
);
}
#[test]
fn configured_term_is_exported_decoded() {
use std::os::unix::ffi::OsStrExt;
let dir = ghostty_os::TempDir::init().unwrap();
for raw in [&b"x\xfft"[..], "t\u{fdd0}".as_bytes()] {
let mut cfg = Config::new(EnvMap::new(), EncodedOs::from_bytes(raw));
cfg.resources_dir = Some(EncodedOs::from_path(&dir.path()));
let exec = Exec::new(cfg).unwrap();
assert_eq!(exec.env().unwrap().get("TERM").unwrap().as_bytes(), raw);
}
}
#[test]
fn exec_command_darwin_hushlogin_with_non_utf8_home() {
use std::os::unix::ffi::OsStrExt;
let tmp = ghostty_os::TempDir::init().unwrap();
let home = tmp.path().join(std::ffi::OsStr::from_bytes(b"h\xffome"));
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join(".hushlogin"), "").unwrap();
// The passwd entry carries the *encoded* home; it must be decoded
// before the lookup.
let enc = EncodedOs::from_path(&home);
assert_ne!(enc.as_encoded().as_bytes(), home.as_os_str().as_bytes());
let passwd = move || {
Ok(ghostty_os::passwd::Entry {
name: Some(EncodedOs::from_encoded("u")),
home: Some(enc.clone()),
..Default::default()
})
};
let result = exec_command_with(&ConfigCommand::Direct(vec!["x".into()]), true, &passwd);
assert_eq!(result, ["/usr/bin/login", "-q", "-flp", "u", "x"]);
}
fn pw(name: Option<&str>) -> impl Fn() -> std::io::Result<ghostty_os::passwd::Entry> {
let name = name.map(str::to_owned);
move || {
Ok(ghostty_os::passwd::Entry {
name: name.clone().map(EncodedOs::from_encoded),
..Default::default()
})
}
}
#[test]
fn exec_command_darwin_shell_command() {
let result = exec_command_with(
&ConfigCommand::Shell("foo bar baz".into()),
true,
&pw(Some("testuser")),
);
assert_eq!(
result,
[
"/usr/bin/login",
"-flp",
"testuser",
"/bin/bash",
"--noprofile",
"--norc",
"-c",
"exec -l foo bar baz"
]
);
}
#[test]
fn exec_command_darwin_direct_command() {
let result = exec_command_with(
&ConfigCommand::Direct(vec!["foo".into(), "bar baz".into()]),
true,
&pw(Some("testuser")),
);
assert_eq!(
result,
["/usr/bin/login", "-flp", "testuser", "foo", "bar baz"]
);
}
#[test]
fn exec_command_darwin_hushlogin() {
let dir = std::env::temp_dir().join(format!("ghostty-hush-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".hushlogin"), "").unwrap();
let home = EncodedOs::from_path(&dir);
let passwd = move || {
Ok(ghostty_os::passwd::Entry {
name: Some(EncodedOs::from_encoded("u")),
home: Some(home.clone()),
..Default::default()
})
};
let result = exec_command_with(&ConfigCommand::Direct(vec!["x".into()]), true, &passwd);
assert_eq!(result, ["/usr/bin/login", "-q", "-flp", "u", "x"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn exec_command_darwin_falls_back_without_username() {
// Empty passwd entry means we can't construct a macOS login
// command and falls back to POSIX behavior.
let result =
exec_command_with(&ConfigCommand::Shell("foo bar baz".into()), true, &pw(None));
assert_eq!(result, ["/bin/sh", "-c", "foo bar baz"]);
// Failed passwd entry also falls back.
let fail = || Err(std::io::Error::other("fail"));
let result = exec_command_with(
&ConfigCommand::Direct(vec!["foo".into(), "bar baz".into()]),
true,
&fail,
);
assert_eq!(result, ["foo", "bar baz"]);
}
#[test]
fn exec_command_shell_command() {
// execCommand: shell command, empty passwd
let result = exec_command_with(
&ConfigCommand::Shell("foo bar baz".into()),
false,
&pw(None),
);
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);
}
#[cfg(unix)]
fn pid_of(child: &std::process::Child) -> Pid {
Pid::from_raw(i32::try_from(child.id()).unwrap()).unwrap()
}
/// Like Zig, there is no cap on waiting for the child's `setsid`: a
/// child that creates its group later than the old ~5 s cap's
/// granularity is still found (a short delay here keeps the test fast).
#[cfg(unix)]
#[test]
fn getpgid_waits_for_late_setsid() {
let mut child = std::process::Command::new("sh")
.args(["-c", "sleep 0.4; exec setsid sleep 3"])
.spawn()
.unwrap();
let pid = pid_of(&child);
let start = std::time::Instant::now();
let pgid = getpgid(pid);
assert_eq!(pgid, Some(pid));
assert!(start.elapsed() >= Duration::from_millis(300));
let _ = child.kill();
let _ = child.wait();
}
/// Deviation from Zig (DEVIANT_FIXES.md): a child that exited without
/// ever calling `setsid` (unreaped zombie, pgid == ours) does not make
/// the wait spin forever.
#[cfg(unix)]
#[test]
fn getpgid_gives_up_on_exited_child_without_setsid() {
let mut child = std::process::Command::new("true").spawn().unwrap();
let pid = pid_of(&child);
std::thread::sleep(Duration::from_millis(200));
let start = std::time::Instant::now();
assert_eq!(getpgid(pid), None);
assert!(start.elapsed() < Duration::from_secs(2));
let _ = child.wait();
}
}