Zig parses config values (cli/args.zig, Config.zig field parsers, config/string.zig) from raw byte slices, and each field type decides whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The port escape-encoded raw bytes into Strings at the line reader and let fields parse the escaped text, so fields could observe encoding artifacts; successive audits kept finding such leaks (RV5-RV8). Field parsers now receive the original bytes: text fields validate UTF-8 where Zig does, byte/path fields store bytes (EncodedOs/OsString). This resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md), each with a regression test: Windows path conversion fails with BadPathName instead of substituting U+FFFD; Windows process launch uses strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired- surrogate encodings like Zig; literal U+FDD0 Unicode key triggers; selection-word-chars and clipboard-codepoint-map reject invalid UTF-8; font style / codepoint-map family names use original bytes; C title actions forward bytes; D-Bus new-window/Flatpak conversions match Zig. Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md. Also moves the bundled iTerm2-Color-Schemes license out of resources/themes/ so +list-themes no longer lists it as a theme (output is now byte-identical to Ghostty 1.3.1). Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator. Test Plan: - cargo test --workspace --no-fail-fast (3428 passed, exit 0) - just check-macos (both), just check-windows, just check-wasm - +show-config --default --docs, +list-keybinds, +list-actions --docs, +list-themes --plain compared with /usr/bin/ghostty
1739 lines
63 KiB
Rust
1739 lines
63 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 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) => {
|
|
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(¤t, &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(¤t, &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. (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.
|
|
///
|
|
/// 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);
|
|
}
|
|
}
|