feat(macos): port Ghostty's macOS code paths (Metal, CoreText, Darwin)

Ports the macOS-specific Zig code behind cfg(target_os = "macos"):
- renderer: a GraphicsApi trait with generic::Renderer<G>; Metal backend
  (all renderer/metal/* files, shaders.metal verbatim, runtime library
  compile, IOSurface layer, CVDisplayLink), shadertoy -> MSL via naga;
  OpenGL remains the backend elsewhere.
- font: CoreText face, shaper and discovery with build.rs backend
  selection (coretext default; coretext-freetype / coretext-harfbuzz /
  coretext-noshape features), no fontconfig/freetype/harfbuzz on Apple.
- input: KeymapDarwin via UCKeyTranslate, option-as-alt, mac keycodes.
- os: macos, mach, cf_release_thread, NSProcessInfo args and macOS
  branches; config: macOS defaults, keybinds, Application Support paths.
- termio/core: login(1) wrapping, openpty/execvpe, Darwin child exit
  handling, os_log logging.

Compile-checked for aarch64/x86_64-apple-darwin (clippy -D warnings
clean) using the cross setup in docs/rewrite/notes/macos-M1.md; cannot
run here. The embedded runtime/C API (embedded.zig) is the next step.

Also includes the cross-platform integration edits shared by the crash,
verification, Windows and macOS work (Cargo target-gating, thread
crash-context hooks, apprt-gtk surface/IME refactor, termio/os cfg
gates), a cfg fix so ghostty-config builds on wasm32, and an allow for
the intentional recursion in the crash overflow test.

Test Plan:
- cargo test --workspace (3224 passed)
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check --target aarch64-apple-darwin -p ghostty-renderer (with
  the documented cross env)
This commit is contained in:
ddidderr committed 2026-10-12 06:02:39 +02:00
1 parent 3c8fae1089
commit 452b782149
124 files changed
+14936 -1242

No files matched your search

+103 -1
View File
@@ -221,9 +221,37 @@ impl std::fmt::Debug for Command {
/// Everything that must be allocated before forking.
struct Prepared {
#[cfg_attr(target_vendor = "apple", allow(dead_code))]
path: CString,
argv: Vec<CString>,
envp: Vec<CString>,
/// Darwin has no `execvpe`: the candidate executable paths (the PATH
/// search of Zig's `execvpeZ`, precomputed so the child doesn't allocate).
#[cfg(target_vendor = "apple")]
candidates: Vec<CString>,
}
/// The default PATH used when `PATH` is unset (Zig `std.posix.execvpeZ`:
/// `/usr/local/bin:/bin/:/usr/bin`).
#[cfg(any(target_vendor = "apple", test))]
const DEFAULT_PATH: &str = "/usr/local/bin:/bin/:/usr/bin";
/// The paths `execvpe` tries for `file` given a PATH value: the file itself
/// if it contains a slash, otherwise `<dir>/<file>` per PATH entry (an empty
/// entry means the current directory).
#[cfg(any(target_vendor = "apple", test))]
fn exec_candidates(file: &str, path_env: Option<&str>) -> Vec<String> {
if file.contains('/') {
return vec![file.to_owned()];
}
path_env
.unwrap_or(DEFAULT_PATH)
.split(':')
.map(|dir| {
let dir = if dir.is_empty() { "." } else { dir };
format!("{dir}/{file}")
})
.collect()
}
impl Command {
@@ -266,7 +294,18 @@ impl Command {
})
.collect::<Result<_, _>>()?,
};
Ok(Prepared { path, argv, envp })
#[cfg(target_vendor = "apple")]
let candidates = exec_candidates(&self.path, std::env::var("PATH").ok().as_deref())
.into_iter()
.map(CString::new)
.collect::<Result<Vec<_>, _>>()?;
Ok(Prepared {
path,
argv,
envp,
#[cfg(target_vendor = "apple")]
candidates,
})
}
/// Start the subprocess. This returns immediately once the child is
@@ -409,6 +448,16 @@ impl Command {
fn setup_fd(dup: impl Fn(&OwnedFd) -> rustix::io::Result<()>, src: &OwnedFd) -> Result<(), Errno> {
// dup2 (like Zig's dup3 with flags=0) leaves the target without
// CLOEXEC: we do NOT want this file descriptor to be closed on exec.
//
// Mac doesn't support dup3 so Zig uses dup2 there, and purposely
// clears CLOEXEC on the source fd first.
#[cfg(target_vendor = "apple")]
{
let flags = rustix::io::fcntl_getfd(src)?;
if flags.contains(rustix::io::FdFlags::CLOEXEC) {
rustix::io::fcntl_setfd(src, flags - rustix::io::FdFlags::CLOEXEC)?;
}
}
loop {
match dup(src) {
Err(Errno::INTR) => {}
@@ -442,6 +491,7 @@ fn fork() -> Result<i32, Errno> {
}
}
#[cfg(not(target_vendor = "apple"))]
#[allow(unsafe_code)]
fn execvpe(prep: &Prepared, argv: &[*const c_char], envp: &[*const c_char]) -> Errno {
debug_assert!(argv.last().is_some_and(|p| p.is_null()));
@@ -455,12 +505,50 @@ fn execvpe(prep: &Prepared, argv: &[*const c_char], envp: &[*const c_char]) -> E
Errno::from_raw_os_error(errno_now())
}
/// Darwin has no `execvpe`; do the PATH search by hand like Zig's
/// `execvpeZ` (EACCES is remembered, ENOENT/ENOTDIR continue the search,
/// anything else is returned immediately).
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
fn execvpe(prep: &Prepared, argv: &[*const c_char], envp: &[*const c_char]) -> Errno {
debug_assert!(argv.last().is_some_and(|p| p.is_null()));
debug_assert!(envp.last().is_some_and(|p| p.is_null()));
let mut seen_eacces = false;
for candidate in &prep.candidates {
// SAFETY: the candidate is a valid NUL-terminated string; argv/envp
// are NULL-terminated arrays of pointers into CStrings (owned by
// `prep`) that outlive the call. execve only returns on failure.
unsafe {
libc::execve(candidate.as_ptr(), argv.as_ptr(), envp.as_ptr());
}
let err = errno_now();
match err {
libc::EACCES => seen_eacces = true,
libc::ENOENT | libc::ENOTDIR => {}
_ => return Errno::from_raw_os_error(err),
}
}
Errno::from_raw_os_error(if seen_eacces {
libc::EACCES
} else {
libc::ENOENT
})
}
#[cfg(not(target_vendor = "apple"))]
#[allow(unsafe_code)]
fn errno_now() -> i32 {
// SAFETY: __errno_location returns a valid thread-local pointer.
unsafe { *libc::__errno_location() }
}
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
fn errno_now() -> i32 {
// SAFETY: __error returns a valid thread-local pointer.
unsafe { *libc::__error() }
}
fn write_stderr(msg: &[u8]) {
// Best effort, no allocation.
let _ = rustix::io::write(borrow_stderr(), msg);
@@ -910,4 +998,18 @@ mod tests {
assert!(linux_cgroup_post_fork(&cmd, &hard, |_, _, _| None).is_err());
assert!(linux_cgroup_post_fork(&cmd, &hard, |_, _, _| Some(Err("x".into()))).is_err());
}
#[test]
fn exec_candidates_search_path() {
assert_eq!(exec_candidates("/bin/sh", Some("/x")), ["/bin/sh"]);
assert_eq!(exec_candidates("a/b", None), ["a/b"]);
assert_eq!(
exec_candidates("sh", Some("/usr/bin::/bin")),
["/usr/bin/sh", "./sh", "/bin/sh"]
);
assert_eq!(
exec_candidates("sh", None),
["/usr/local/bin/sh", "/bin//sh", "/usr/bin/sh"]
);
}
}
+303 -10
View File
@@ -37,7 +37,7 @@ use ghostty_renderer::size::{GridSize, ScreenSize};
use ghostty_terminal::terminal::Terminal;
use rustix::{
event::{PollFd, PollFlags},
process::{Pid, PidfdFlags, Signal, WaitOptions},
process::{Pid, Signal, WaitOptions},
};
use crate::{
@@ -254,9 +254,17 @@ impl Exec {
set_nonblocking(&read_fd);
// Start our read thread
let read_thread = std::thread::Builder::new()
.name("io-reader".into())
.spawn(move || read_thread_main(&read_fd, &output, &quit_read))?;
// 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,
@@ -339,7 +347,9 @@ impl Exec {
/// How we notice the child exiting.
enum ProcessWatch {
/// A pidfd that becomes readable when the process exits.
/// 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),
@@ -354,13 +364,19 @@ impl ProcessWatch {
let Some(p) = Pid::from_raw(pid) else {
return ProcessWatch::Done;
};
match rustix::process::pidfd_open(p, PidfdFlags::empty()) {
#[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))
}
}
}
@@ -416,6 +432,7 @@ impl ThreadData {
/// 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,
}
@@ -513,7 +530,9 @@ impl ThreadData {
/// reap the child and report the exit.
pub fn on_process_ready(&mut self, cmd_pid: Option<i32>) {
let pid = match &self.watch {
ProcessWatch::PidFd(_, pid) | ProcessWatch::Poll(pid, _) => *pid,
#[cfg(any(target_os = "linux", target_os = "android"))]
ProcessWatch::PidFd(_, pid) => *pid,
ProcessWatch::Poll(pid, _) => *pid,
_ => return,
};
let _ = cmd_pid;
@@ -802,6 +821,12 @@ impl Subprocess {
};
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");
}
@@ -809,6 +834,14 @@ impl Subprocess {
// 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");
@@ -1189,6 +1222,34 @@ fn add_ghostty_path(env: &mut EnvMap) {
}
}
/// macOS: export additional data directories from our application bundle
/// (`XDG_DATA_DIRS` and `MANPATH`).
pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &str) {
let xdg_data_dir_key = "XDG_DATA_DIRS";
let data_dir = format!("{resources_dir}/..");
let current = env.get(xdg_data_dir_key).map_or_else(
|| "/usr/local/share:/usr/share".to_owned(),
|v| v.to_string_lossy().into_owned(),
);
env.insert(
xdg_data_dir_key,
ghostty_os::append_env(&current, &data_dir),
);
let manpath_key = "MANPATH";
let man_dir = format!("{resources_dir}/../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(String::new, |v| v.to_string_lossy().into_owned());
env.insert(
manpath_key,
ghostty_os::append_env_always(&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> {
@@ -1216,6 +1277,10 @@ fn kill_pid(pid_raw: i32) -> Result<(), Error> {
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}",
@@ -1286,9 +1351,34 @@ fn getpgid(pid: Pid) -> Option<Pid> {
/// configured command. This isn't as straightforward as it seems since
/// we deal with shell-wrapping, etc.
///
/// (The macOS `login(1)` branch of the Zig function is not ported, Linux
/// only; neither is the Windows `cmd.exe` branch.)
/// 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.clone(),
@@ -1312,6 +1402,124 @@ pub fn exec_command(command: &ConfigCommand) -> Vec<String> {
}
}
/// 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) => {
if std::fs::read_dir(home).is_err() {
log::warn!("failed to open home dir, not checking for hushlogin");
false
} else {
std::path::Path::new(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);
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().cloned()),
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}");
// 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).
@@ -1328,10 +1536,95 @@ impl Drop for ThreadData {
mod tests {
use super::*;
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(),
..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 = dir.to_str().unwrap().to_owned();
let passwd = move || {
Ok(ghostty_os::passwd::Entry {
name: Some("u".into()),
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(&ConfigCommand::Shell("foo bar baz".into()));
let result = exec_command_with(
&ConfigCommand::Shell("foo bar baz".into()),
false,
&pw(None),
);
assert_eq!(result, ["/bin/sh", "-c", "foo bar baz"]);
}
+8 -1
View File
@@ -1,6 +1,7 @@
//! ghostty-termio: see docs/rewrite/ARCHITECTURE.md for the module map.
//!
//! Linux only. Ported: `pty.zig`, `Command.zig`, the `terminfo/*` source
//! Linux, with a Windows (ConPTY) port behind `cfg(windows)` (the `*_windows.rs`
//! files, selected with `#[path]`). Ported: `pty.zig`, `Command.zig`, the `terminfo/*` source
//! generator and everything in `termio/` (`Options`, `backend`, `message`,
//! `mailbox`, `Termio`, `Thread`, `Exec`, `stream_handler`,
//! `shell_integration`). See `docs/rewrite/notes/ghostty-termio-IO1.md` and
@@ -19,11 +20,14 @@
)]
pub mod backend;
#[cfg_attr(windows, path = "command_windows.rs")]
pub mod command;
#[cfg_attr(windows, path = "exec_windows.rs")]
pub mod exec;
pub mod mailbox;
pub mod message;
pub mod options;
#[cfg_attr(windows, path = "pty_windows.rs")]
pub mod pty;
pub mod shell_integration;
pub mod sinks;
@@ -32,7 +36,10 @@ pub mod stream_handler;
pub mod terminfo;
pub mod termio;
pub mod thread;
#[cfg_attr(windows, path = "wakeup_windows.rs")]
pub mod wakeup;
/// Pure Windows `CreateProcessW` input encoders (tested on every host).
pub mod windows_args;
pub use backend::Backend;
pub use command::Command;
+27 -10
View File
@@ -152,6 +152,31 @@ mod tests {
assert!(matches!(back, Message::Focused(true)));
}
/// Block until the wakeup is signaled.
#[cfg(unix)]
fn wait_readable(w: &Wakeup) {
let mut fds = [rustix::event::PollFd::from_borrowed_fd(
w.as_fd(),
rustix::event::PollFlags::IN,
)];
rustix::event::poll(&mut fds, None).unwrap();
}
/// Block until the wakeup is signaled (this consumes the auto-reset
/// event; the callers' `drain` after it is then a harmless no-op).
#[cfg(windows)]
#[allow(unsafe_code)]
fn wait_readable(w: &Wakeup) {
use std::os::windows::io::AsRawHandle;
// SAFETY: valid event handle owned by the wakeup.
unsafe {
windows_sys::Win32::System::Threading::WaitForSingleObject(
w.as_handle().as_raw_handle().cast(),
windows_sys::Win32::System::Threading::INFINITE,
)
};
}
#[test]
fn blocking_send_wakes_consumer_and_completes() {
let mb = Mailbox::new().unwrap();
@@ -161,11 +186,7 @@ mod tests {
let consumer = mb.clone();
let t = std::thread::spawn(move || {
// Wait for the wakeup the full-queue path posts, then drain.
let mut fds = [rustix::event::PollFd::from_borrowed_fd(
consumer.wakeup().as_fd(),
rustix::event::PollFlags::IN,
)];
rustix::event::poll(&mut fds, None).unwrap();
wait_readable(consumer.wakeup());
consumer.wakeup().drain();
consumer.drain().count()
});
@@ -185,11 +206,7 @@ mod tests {
let m2 = Arc::clone(&m);
// The consumer needs the lock to make progress (like the IO thread).
let t = std::thread::spawn(move || {
let mut fds = [rustix::event::PollFd::from_borrowed_fd(
consumer.wakeup().as_fd(),
rustix::event::PollFlags::IN,
)];
rustix::event::poll(&mut fds, None).unwrap();
wait_readable(consumer.wakeup());
let mut g = m2.lock().unwrap();
*g += 1;
consumer.drain().count()
+76 -17
View File
@@ -4,7 +4,9 @@
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
use rustix::{fs, process, pty, termios};
#[cfg(not(target_vendor = "apple"))]
use rustix::{fs, pty};
use rustix::{process, termios};
/// Redeclare the winsize struct so we can just use a Rust struct. The
/// defaults on this are some reasonable screen size but you should probably
@@ -106,25 +108,82 @@ pub struct Pty {
pub slave: Option<OwnedFd>,
}
/// Equivalent of glibc openpty(): the master is CLOEXEC (Ghostty sets this
/// explicitly: only the slave fd should be inherited by the child), the
/// slave is not.
#[cfg(not(target_vendor = "apple"))]
fn open_pair(_size: Winsize) -> Result<(OwnedFd, OwnedFd), Error> {
let master =
pty::openpt(pty::OpenptFlags::RDWR | pty::OpenptFlags::NOCTTY | pty::OpenptFlags::CLOEXEC)
.map_err(|_| Error::OpenptyFailed)?;
pty::grantpt(&master).map_err(|_| Error::OpenptyFailed)?;
pty::unlockpt(&master).map_err(|_| Error::OpenptyFailed)?;
let name = pty::ptsname(&master, Vec::new()).map_err(|_| Error::OpenptyFailed)?;
let slave = fs::open(
name.as_c_str(),
fs::OFlags::RDWR | fs::OFlags::NOCTTY,
fs::Mode::empty(),
)
.map_err(|_| Error::OpenptyFailed)?;
Ok((master, slave))
}
/// Darwin: libc `openpty()` (`util.h`), exactly as Zig does, followed by
/// setting CLOEXEC on the master fd (only the slave fd should be inherited
/// by the child process).
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
fn open_pair(size: Winsize) -> Result<(OwnedFd, OwnedFd), Error> {
use std::os::fd::FromRawFd;
let mut ws = libc::winsize {
ws_row: size.ws_row,
ws_col: size.ws_col,
ws_xpixel: size.ws_xpixel,
ws_ypixel: size.ws_ypixel,
};
let mut master_fd: libc::c_int = -1;
let mut slave_fd: libc::c_int = -1;
// SAFETY: out-pointers reference live locals; the name/termios
// arguments are allowed to be null; `ws` is only read.
let rc = unsafe {
libc::openpty(
&raw mut master_fd,
&raw mut slave_fd,
std::ptr::null_mut(),
std::ptr::null_mut(),
&raw mut ws,
)
};
if rc < 0 {
return Err(Error::OpenptyFailed);
}
// SAFETY: openpty succeeded so both fds are valid and exclusively ours.
let (master, slave) = unsafe {
(
OwnedFd::from_raw_fd(master_fd),
OwnedFd::from_raw_fd(slave_fd),
)
};
// Set CLOEXEC on the master fd, only the slave fd should be inherited
// by the child process (shell/command).
match rustix::io::fcntl_getfd(&master) {
Ok(flags) => {
if let Err(err) = rustix::io::fcntl_setfd(&master, flags | rustix::io::FdFlags::CLOEXEC)
{
log::warn!("error setting CLOEXEC on master fd err={err}");
}
}
Err(err) => log::warn!("error getting flags for master fd err={err}"),
}
Ok((master, slave))
}
impl Pty {
/// Open a new PTY with the given initial size.
pub fn open(size: Winsize) -> Result<Pty, Error> {
// Equivalent of glibc openpty(): the master is CLOEXEC (Ghostty sets
// this explicitly: only the slave fd should be inherited by the
// child), the slave is not.
let master = pty::openpt(
pty::OpenptFlags::RDWR | pty::OpenptFlags::NOCTTY | pty::OpenptFlags::CLOEXEC,
)
.map_err(|_| Error::OpenptyFailed)?;
pty::grantpt(&master).map_err(|_| Error::OpenptyFailed)?;
pty::unlockpt(&master).map_err(|_| Error::OpenptyFailed)?;
let name = pty::ptsname(&master, Vec::new()).map_err(|_| Error::OpenptyFailed)?;
let slave = fs::open(
name.as_c_str(),
fs::OFlags::RDWR | fs::OFlags::NOCTTY,
fs::Mode::empty(),
)
.map_err(|_| Error::OpenptyFailed)?;
let (master, slave) = open_pair(size)?;
termios::tcsetwinsize(&master, size.into()).map_err(|_| Error::OpenptyFailed)?;
+27 -2
View File
@@ -101,13 +101,30 @@ fn basename(path: &str) -> &str {
}
fn detect_shell(command: &Command) -> Option<Shell> {
detect_shell_for(command, cfg!(target_vendor = "apple"))
}
/// [`detect_shell`] for an explicit platform (`darwin == isDarwin()`).
fn detect_shell_for(command: &Command, darwin: bool) -> Option<Shell> {
let mut it = command.arg_iterator();
let arg0 = it.next()?;
let exe = basename(&arg0);
match exe {
// (The Darwin /bin/bash special case does not apply on Linux.)
"bash" => Some(Shell::Bash),
"bash" => {
// Apple distributes their own patched version of Bash 3.2
// on macOS that disables the ENV-based POSIX startup path.
// This means we're unable to perform our automatic shell
// integration sequence in this specific environment.
//
// If we're running "/bin/bash" on Darwin, we can assume
// we're using Apple's Bash because /bin is non-writable
// on modern macOS due to System Integrity Protection.
if darwin && arg0 == "/bin/bash" {
return None;
}
Some(Shell::Bash)
}
"elvish" => Some(Shell::Elvish),
"fish" => Some(Shell::Fish),
"nu" => Some(Shell::Nushell),
@@ -450,6 +467,14 @@ mod tests {
assert_eq!(detect_shell(&sh("nu")), Some(Shell::Nushell));
assert_eq!(detect_shell(&sh("zsh")), Some(Shell::Zsh));
// Apple's patched /bin/bash can't use the ENV-based integration.
assert_eq!(detect_shell_for(&sh("/bin/bash"), true), None);
assert_eq!(detect_shell_for(&sh("/bin/bash"), false), Some(Shell::Bash));
assert_eq!(
detect_shell_for(&sh("/opt/homebrew/bin/bash"), true),
Some(Shell::Bash)
);
assert_eq!(detect_shell(&sh("bash -c 'command'")), Some(Shell::Bash));
assert_eq!(detect_shell(&sh("\"/a b/bash\"")), Some(Shell::Bash));
assert_eq!(detect_shell(&sh("/usr/bin/zsh")), Some(Shell::Zsh));
+18 -2
View File
@@ -27,17 +27,22 @@ use ghostty_apprt::surface::Message as SurfaceMessage;
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::size::Size;
use ghostty_vt::{csi, modes::Mode};
#[cfg(unix)]
use rustix::event::{PollFd, PollFlags};
use crate::{
backend::ThreadData as BackendThreadData,
exec,
message::Message,
state::SharedStateExt,
termio::{Error, Termio, ThreadData},
wakeup::Wakeup,
};
/// The Windows event loop (`WaitForMultipleObjects`).
#[cfg(windows)]
#[path = "thread_windows.rs"]
mod windows_loop;
/// The number of milliseconds to coalesce certain messages like resize for.
/// Not all message types are coalesced.
pub const COALESCE_MIN_MS: u64 = 25;
@@ -81,6 +86,9 @@ pub struct Thread {
sync_reset_deadline: Option<Instant>,
flags: Flags,
/// The surface dimensions recorded in crash reports.
pub crash_dims: Option<std::sync::Arc<ghostty_crash::SurfaceDims>>,
}
impl Thread {
@@ -96,6 +104,7 @@ impl Thread {
coalesce_resize: None,
sync_reset_deadline: None,
flags: Flags::default(),
crash_dims: None,
})
}
@@ -109,6 +118,10 @@ impl Thread {
/// once the stop handle was notified; dropping the termio afterwards
/// cleans everything up.
pub fn thread_main(mut self, mut io: Termio) {
// Setup our crash metadata
let _crash =
ghostty_crash::ThreadGuard::new(ghostty_crash::ThreadType::Io, self.crash_dims.clone());
// Call child function so we can use errors...
let result = self.thread_main_(&mut io);
if let Err(err) = result {
@@ -140,6 +153,7 @@ impl Thread {
}
/// After an abnormal failure: discard messages until the stop signal.
#[cfg(unix)]
fn drain_until_stop(&mut self, io: &Termio) {
let mailbox = io.mailbox().clone();
loop {
@@ -183,6 +197,7 @@ impl Thread {
}
/// The event loop. Returns when stopped.
#[cfg(unix)]
fn run(&mut self, io: &mut Termio, td: &mut ThreadData) {
let mailbox = io.mailbox().clone();
@@ -386,7 +401,8 @@ fn report_start_error(io: &Termio, err: &Error) {
t.modes.set(Mode::CursorVisible, false);
let msg: String = match err {
Error::Exec(exec::Error::Pty(crate::pty::Error::OpenptyFailed)) => "\
#[cfg(unix)]
Error::Exec(crate::exec::Error::Pty(crate::pty::Error::OpenptyFailed)) => "\
Your system cannot allocate any more pty devices.
Ghostty requires a pty device to launch a new terminal.
+56 -16
View File
@@ -1,7 +1,8 @@
//! A tiny cross-thread wakeup primitive (replaces `xev.Async`).
//!
//! Backed by a non-blocking `eventfd`, so it can be included in a `poll(2)`
//! set next to a pty fd or a pidfd. [`Wakeup::notify`] may be called from any
//! Backed by a non-blocking `eventfd` (a non-blocking pipe on Darwin, which
//! has no eventfd; libxev uses kqueue there), so it can be included in a
//! `poll(2)` set next to a pty fd or a pidfd. [`Wakeup::notify`] may be called from any
//! thread, any number of times; the waiter calls [`Wakeup::drain`] after
//! `poll` reports the fd readable.
@@ -11,30 +12,65 @@ use std::{
sync::Arc,
};
use rustix::event::{EventfdFlags, eventfd};
/// The fds backing a wakeup: a single eventfd (read == write) on Linux, the
/// two ends of a pipe on Darwin.
struct Fds {
read: OwnedFd,
write: Option<OwnedFd>,
}
/// See the module docs. Cloning shares the same underlying eventfd.
impl Fds {
#[cfg(not(target_vendor = "apple"))]
fn new() -> io::Result<Fds> {
use rustix::event::{EventfdFlags, eventfd};
Ok(Fds {
read: eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?,
write: None,
})
}
#[cfg(target_vendor = "apple")]
fn new() -> io::Result<Fds> {
use rustix::{
fs::{OFlags, fcntl_getfl, fcntl_setfl},
io::{FdFlags, fcntl_setfd},
};
let (read, write) = rustix::pipe::pipe()?;
for fd in [&read, &write] {
fcntl_setfd(fd, FdFlags::CLOEXEC)?;
fcntl_setfl(fd, fcntl_getfl(fd)? | OFlags::NONBLOCK)?;
}
Ok(Fds {
read,
write: Some(write),
})
}
fn write_fd(&self) -> &OwnedFd {
self.write.as_ref().unwrap_or(&self.read)
}
}
/// See the module docs. Cloning shares the same underlying fds.
#[derive(Clone)]
pub struct Wakeup(Arc<OwnedFd>, Option<Arc<dyn Fn() + Send + Sync>>);
pub struct Wakeup(Arc<Fds>, Option<Arc<dyn Fn() + Send + Sync>>);
impl std::fmt::Debug for Wakeup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("Wakeup").field(&self.0).finish()
f.debug_tuple("Wakeup").field(&self.0.read).finish()
}
}
impl Wakeup {
pub fn new() -> io::Result<Wakeup> {
let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?;
Ok(Wakeup(Arc::new(fd), None))
Ok(Wakeup(Arc::new(Fds::new()?), None))
}
/// A wakeup that calls `hook` instead of signalling an eventfd. Used to
/// bridge into waiters that are not `poll(2)`-based (the renderer
/// thread's channel). `as_fd`/`drain` are meaningless for these.
pub fn with_hook(hook: Arc<dyn Fn() + Send + Sync>) -> io::Result<Wakeup> {
let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?;
Ok(Wakeup(Arc::new(fd), Some(hook)))
Ok(Wakeup(Arc::new(Fds::new()?), Some(hook)))
}
/// Wake up the waiter. Multiple notifications coalesce.
@@ -44,7 +80,7 @@ impl Wakeup {
return Ok(());
}
loop {
match rustix::io::write(&*self.0, &1u64.to_ne_bytes()) {
match rustix::io::write(self.0.write_fd(), &1u64.to_ne_bytes()) {
Ok(_) => return Ok(()),
Err(rustix::io::Errno::INTR) => {}
// Counter would overflow: the waiter is already guaranteed
@@ -58,19 +94,23 @@ impl Wakeup {
/// Reset the notification state. Returns true if a notification was
/// pending.
pub fn drain(&self) -> bool {
let mut buf = [0u8; 8];
let mut buf = [0u8; 64];
// An eventfd resets with one read; a pipe is read until empty.
let mut any = false;
loop {
match rustix::io::read(&*self.0, &mut buf) {
Ok(_) => return true,
match rustix::io::read(&self.0.read, &mut buf) {
Ok(_) if self.0.write.is_none() => return true,
Ok(0) => return any,
Ok(_) => any = true,
Err(rustix::io::Errno::INTR) => {}
Err(_) => return false,
Err(_) => return any,
}
}
}
/// The fd to poll for `POLLIN`.
pub fn as_fd(&self) -> BorrowedFd<'_> {
self.0.as_fd()
self.0.read.as_fd()
}
}