From f9094c81524dd389ca7a317a8ceb0bc0b3aa4d4b Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 14:49:00 +0200 Subject: [PATCH] feat: honor async-backend with epoll/io_uring, fix last RV13 items Resolves the 4 P3 items in docs/rewrite/notes/review-rv13-codex.md (final-Z16.md): - async-backend now takes effect like Ghostty: ghostty_os::xev ports libxev's backend detection (io_uring, then epoll); global init detects and logs it, the GTK startup applies the config with Zig's messages, and +version reports the real backend. The termio IO loop runs on a new reactor with epoll, io_uring (io-uring crate) and poll(2) (kept for macOS, where libxev uses kqueue). The 21 exec integration tests run under every backend; io_uring was exercised here. - auto-update-channel defaults to the build's release channel. - Windows error names: sharing violation -> AccessDenied, plus ERROR_FILE_EXISTS -> PathAlreadyExists and virus errors -> AntivirusInterference, mirroring std.os.windows.OpenFile's NTSTATUS mapping in Zig 0.15.2 (verified against its source by the orchestrator). - Exact ports of std.fs.path.basenameWindows/basenamePosix for theme name validation (drive, UNC and device roots). Test Plan: - cargo test --workspace --no-fail-fast (3555 passed, exit 0) - just check-macos (both), just check-windows, just check-wasm - +show-config --default --docs vs /usr/bin/ghostty (only the build-specific gtk-opengl-debug line differs) --- Cargo.lock | 14 + .../src/class/application.rs | 70 ++ crates/ghostty-cli/src/version.rs | 3 +- crates/ghostty-config/Cargo.toml | 1 + crates/ghostty-config/src/config.rs | 39 +- crates/ghostty-config/src/theme.rs | 172 ++++- crates/ghostty-core/src/global.rs | 13 + crates/ghostty-os/Cargo.toml | 4 + crates/ghostty-os/src/lib.rs | 1 + crates/ghostty-os/src/xev.rs | 214 ++++++ crates/ghostty-os/src/zig_error.rs | 143 ++-- crates/ghostty-termio/Cargo.toml | 4 + crates/ghostty-termio/src/lib.rs | 2 + crates/ghostty-termio/src/reactor.rs | 553 ++++++++++++++ crates/ghostty-termio/src/thread.rs | 181 +++-- .../ghostty-termio/tests/exec_integration.rs | 618 +--------------- .../tests/exec_integration/suite.rs | 680 ++++++++++++++++++ docs/rewrite/notes/final-Z16.md | 42 ++ 18 files changed, 2012 insertions(+), 742 deletions(-) create mode 100644 crates/ghostty-os/src/xev.rs create mode 100644 crates/ghostty-termio/src/reactor.rs create mode 100644 crates/ghostty-termio/tests/exec_integration/suite.rs create mode 100644 docs/rewrite/notes/final-Z16.md diff --git a/Cargo.lock b/Cargo.lock index d377506..4ed3e64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1449,6 +1449,7 @@ name = "ghostty-config" version = "0.1.0" dependencies = [ "const_format", + "ghostty-build-info", "ghostty-input", "ghostty-os", "ghostty-terminal", @@ -1621,6 +1622,7 @@ dependencies = [ "gettext-rs", "ghostty-vt", "ghostty-zigfmt", + "io-uring", "libc", "log", "nix 0.31.3", @@ -1723,6 +1725,7 @@ dependencies = [ "ghostty-renderer", "ghostty-terminal", "ghostty-vt", + "io-uring", "libc", "log", "nix 0.31.3", @@ -2150,6 +2153,17 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "libc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" diff --git a/crates/ghostty-apprt-gtk/src/class/application.rs b/crates/ghostty-apprt-gtk/src/class/application.rs index 82a7e46..81ed9c9 100644 --- a/crates/ghostty-apprt-gtk/src/class/application.rs +++ b/crates/ghostty-apprt-gtk/src/class/application.rs @@ -206,6 +206,33 @@ fn set_gtk_env(config: &CoreConfig) { /// if that fails (usually an invalid `LANG`), retry with `LANG` unset, then fall /// back to `en_US.UTF-8` (updating `LANG` on success). Idempotent. This must /// run before GTK initializes and before the child environment is captured. +/// Apply the `async-backend` config (Zig: `startupXev`): `auto` keeps the +/// backend detected at startup, the others request that backend and fall +/// back to the detected one when it is not available. +pub fn apply_async_backend(backend: ghostty_config::types_b::AsyncBackend) -> bool { + use ghostty_config::types_b::AsyncBackend; + use ghostty_os::xev::{self, Backend}; + + // If our backend is auto then we have no setup to do. + let want = match backend { + AsyncBackend::Auto => return true, + AsyncBackend::Epoll => Backend::Epoll, + AsyncBackend::IoUring => Backend::IoUring, + }; + + // Setup our event loop backend to the preferred method + let result = xev::is_dynamic() && xev::prefer(want); + if result { + log::info!("libxev manual backend={}", xev::backend()); + } else { + log::warn!( + "libxev manual backend failed, using default={}", + xev::backend() + ); + } + result +} + pub fn init_locale() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { @@ -329,6 +356,9 @@ mod imp { // Set ourselves as the default application. gio::Application::set_default(obj.upcast_ref::()); + // Setup our event loop + obj.startup_xev(); + // Setup our style manager (light/dark mode) obj.startup_style_manager(); // Setup some signal handlers @@ -889,6 +919,14 @@ impl Application { // ----------------------------------------------------------------- // Startup + /// Configure the event loop (libxev) to use a specific backend. + /// + /// This must be called before any event loop is created. + fn startup_xev(&self) { + let config = self.core_config(); + apply_async_backend(config.async_backend); + } + /// Setup the style manager on startup: initial light/dark mode based on /// the configuration, plus change listeners. fn startup_style_manager(&self) { @@ -1900,3 +1938,35 @@ mod trim_tests { assert_eq!(trim_ascii_whitespace(" \t "), ""); } } + +#[cfg(test)] +mod async_backend_tests { + use ghostty_config::types_b::AsyncBackend; + use ghostty_os::xev::{self, Backend}; + + use super::apply_async_backend; + + #[test] + fn async_backend_selects_the_event_loop() { + let _ = xev::detect(); + let detected = xev::backend(); + + // auto: nothing to do. + assert!(apply_async_backend(AsyncBackend::Auto)); + assert_eq!(xev::backend(), detected); + + // epoll is always available on Linux. + assert!(apply_async_backend(AsyncBackend::Epoll)); + assert_eq!(xev::backend(), Backend::Epoll); + + // io_uring only when the kernel allows it, else the backend stays. + let ok = apply_async_backend(AsyncBackend::IoUring); + assert_eq!(ok, xev::available(Backend::IoUring)); + assert_eq!( + xev::backend(), + if ok { Backend::IoUring } else { Backend::Epoll } + ); + + let _ = xev::detect(); + } +} diff --git a/crates/ghostty-cli/src/version.rs b/crates/ghostty-cli/src/version.rs index cc885c0..742e4df 100644 --- a/crates/ghostty-cli/src/version.rs +++ b/crates/ghostty-cli/src/version.rs @@ -48,8 +48,7 @@ pub fn write_version(out: &mut dyn Write, info: &BuildInfo, tty: bool) -> std::i writeln!(out, " - app runtime : {}", info.app_runtime)?; writeln!(out, " - font engine : {}", info.font_backend)?; writeln!(out, " - renderer : {}", info.renderer)?; - // There is no libxev in the Rust port. - writeln!(out, " - libxev : n/a")?; + writeln!(out, " - libxev : {}", ghostty_os::xev::backend())?; if info.app_runtime == ".gtk" { let kernel_info = ghostty_os::get_kernel_info(); writeln!( diff --git a/crates/ghostty-config/Cargo.toml b/crates/ghostty-config/Cargo.toml index b2050dc..552c6fb 100644 --- a/crates/ghostty-config/Cargo.toml +++ b/crates/ghostty-config/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] const_format = "0.2.36" +ghostty-build-info.workspace = true ghostty-input.workspace = true ghostty-os.workspace = true ghostty-terminal.workspace = true diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs index a5c0998..d5acf31 100644 --- a/crates/ghostty-config/src/config.rs +++ b/crates/ghostty-config/src/config.rs @@ -28,6 +28,14 @@ use crate::{ const NS_PER_S: u64 = 1_000_000_000; const NS_PER_MS: u64 = 1_000_000; +/// `build_config.release_channel` as a config value. +fn build_release_channel() -> ReleaseChannel { + match ghostty_build_info::RELEASE_CHANNEL { + "tip" => ReleaseChannel::Tip, + _ => ReleaseChannel::Stable, + } +} + config_enum! { /// The release channel of the build (Zig: `build_config.ReleaseChannel`). pub enum ReleaseChannel { @@ -4656,7 +4664,7 @@ impl Config { // We can't set this as a struct default because our config is // loaded in environments where a build config isn't available. if self.auto_update_channel.is_none() { - self.auto_update_channel = Some(ReleaseChannel::Stable); + self.auto_update_channel = Some(build_release_channel()); } self.faint_opacity = cli_args::zig_clamp(self.faint_opacity, 0.0, 1.0); @@ -5027,6 +5035,35 @@ impl CliTarget for Config { clippy::assert_is_empty )] mod tests { + #[test] + fn auto_update_channel_defaults_to_the_build_channel() { + let mut cfg = Config::default(); + assert_eq!(cfg.auto_update_channel, None); + cfg.finalize().unwrap(); + let expected = match ghostty_build_info::RELEASE_CHANNEL { + "tip" => ReleaseChannel::Tip, + "stable" => ReleaseChannel::Stable, + other => panic!("unexpected channel {other}"), + }; + assert_eq!(cfg.auto_update_channel, Some(expected)); + } + + #[test] + fn auto_update_channel_explicit_value_is_kept() { + for (text, channel) in [ + ("stable", ReleaseChannel::Stable), + ("tip", ReleaseChannel::Tip), + ] { + let mut cfg = Config::default(); + let data = format!("auto-update-channel = {text}\n"); + let mut reader = std::io::Cursor::new(data.into_bytes()); + cfg.load_reader(&mut reader, &EncodedOs::from_encoded("/c/config")) + .unwrap(); + cfg.finalize().unwrap(); + assert_eq!(cfg.auto_update_channel, Some(channel)); + } + } + #[cfg(unix)] #[test] fn shell_from_env_round_trips_bytes() { diff --git a/crates/ghostty-config/src/theme.rs b/crates/ghostty-config/src/theme.rs index 714134d..47d72d5 100644 --- a/crates/ghostty-config/src/theme.rs +++ b/crates/ghostty-config/src/theme.rs @@ -151,24 +151,43 @@ fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool { } } -/// `std.fs.path.basename` (last `ComponentIterator` component; `.` and `..` -/// are ordinary names). The caller only passes non-absolute paths, so the root -/// is empty on POSIX and an optional `X:` drive designator on Windows. +/// `std.fs.path.basename` for the host: `basenamePosix` or `basenameWindows`. fn zig_basename(path: &str) -> &str { - let is_sep = |b: u8| b == b'/' || (cfg!(windows) && b == b'\\'); - let bytes = path.as_bytes(); - let root_end = if cfg!(windows) - && bytes.len() >= 2 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - { - 2 + if cfg!(windows) { + basename_windows(path) } else { - 0 - }; + basename_posix(path) + } +} + +/// `std.fs.path.basenamePosix`: the last `ComponentIterator` component. +/// `.` and `..` are ordinary names. +fn basename_posix(path: &str) -> &str { + let root_len = usize::from(path.as_bytes().first() == Some(&b'/')); + basename_inner(path, root_len, |b| b == b'/') +} + +/// `std.fs.path.basenameWindows`. +fn basename_windows(path: &str) -> &str { + basename_inner(path, parse_path_windows_root_len(path), |b| { + b == b'/' || b == b'\\' + }) +} + +/// `ComponentIterator.init` + `last()`: skip the root and any separators after +/// it, then take the last component (empty if there is none). +fn basename_inner(path: &str, root_len: usize, is_sep: impl Fn(u8) -> bool) -> &str { + let bytes = path.as_bytes(); + let mut root_end_index = root_len; + for &c in &bytes[root_len..] { + if !is_sep(c) { + break; + } + root_end_index += 1; + } let mut end = bytes.len(); loop { - if end == root_end { + if end == root_end_index { return ""; } if !is_sep(bytes[end - 1]) { @@ -177,12 +196,73 @@ fn zig_basename(path: &str) -> &str { end -= 1; } let mut start = end; - while start != root_end && !is_sep(bytes[start - 1]) { + while start != root_end_index && !is_sep(bytes[start - 1]) { start -= 1; } &path[start..end] } +/// `std.fs.path.parsePathWindows(u8, path).root.len` (with +/// `getWin32PathType`; the path is WTF-8 so the drive letter is one whole code +/// point of at most three bytes, like `RtlDetermineDosPathNameType_U`). +fn parse_path_windows_root_len(path: &str) -> usize { + let b = path.as_bytes(); + let sep = |c: u8| c == b'/' || c == b'\\'; + let Some(&first) = b.first() else { + return 0; // relative + }; + if sep(first) { + // \x + if b.len() < 2 || !sep(b[1]) { + return 1; // rooted + } + // \\. or \\? + if b.len() > 2 && (b[2] == b'.' || b[2] == b'?') { + // exactly \\. or \\? with nothing trailing + if b.len() == 3 { + return b.len(); // root_local_device + } + // \\.\x or \\?\x + if sep(b[3]) { + return 4; // local_device + } + } + // \\x: UNC absolute (`parseUNC`) + let Some(server_end) = b[2..].iter().position(|&c| sep(c)).map(|i| i + 2) else { + return b.len(); + }; + // Any number of separators may sit between the server and the share. + let share_start = + server_end + 1 + b[server_end + 1..].iter().take_while(|&&c| sep(c)).count(); + if share_start == b.len() { + // No share: the share is the empty slice right after the server + // separator. + return server_end + 1; + } + let share_end = b[share_start..] + .iter() + .position(|&c| sep(c)) + .map_or(b.len(), |i| i + share_start); + // `sep_after_share` adds one more. + return share_end + usize::from(share_end != b.len()); + } + // Drive letter: a code point of 1..=3 bytes followed by ':'. + let colon_i = match first { + 0x00..=0x7f => 1, + 0xc2..=0xdf => 2, + 0xe0..=0xef => 3, + _ => return 0, // invalid or 4-byte sequence: relative + }; + if b.len() < colon_i + 1 || b[colon_i] != b':' { + return 0; // relative + } + if b.len() > colon_i + 1 && sep(b[colon_i + 1]) { + colon_i + 2 // drive_absolute + } else { + colon_i + 1 // drive_relative + } +} + /// Open the given named theme. If there are any errors then messages /// will be appended to the given error list and None is returned. If /// a Some return value is returned, there are never any errors added. @@ -363,6 +443,68 @@ mod tests { ); } + #[test] + fn basename_windows_matches_zig_tests() { + // The `testBasenameWindows` cases of `std/fs/path.zig`. + for (input, expected) in [ + ("\\dir\\basename.ext", "basename.ext"), + ("\\basename.ext", "basename.ext"), + ("basename.ext", "basename.ext"), + ("basename.ext\\", "basename.ext"), + ("basename.ext\\\\", "basename.ext"), + ("foo", "foo"), + ("C:", ""), + ("C:.", "."), + ("C:\\", ""), + ("C:\\dir\\base.ext", "base.ext"), + ("C:\\basename.ext", "basename.ext"), + ("C:basename.ext", "basename.ext"), + ("C:basename.ext\\", "basename.ext"), + ("C:basename.ext\\\\", "basename.ext"), + ("\\\\.", ""), + ("\\\\.\\", ""), + ("\\\\.\\basename.ext", "basename.ext"), + ("\\\\?", ""), + ("\\\\?\\", ""), + ("\\\\?\\basename.ext", "basename.ext"), + ("C:foo", "foo"), + ("file:stream", "file:stream"), + // Non-letter drive designators are drive roots too (the + // RV13 finding): `1:theme` is `theme`, not the whole name. + ("1:theme", "theme"), + ("1:", ""), + ("\u{3bb}:theme", "theme"), + ("\u{20ac}:\\theme", "theme"), + // UNC roots. + ("//a", ""), + ("//a/b", ""), + ("//a/b/c", "c"), + ("\\\\server\\\\share\\file", "file"), + ("//", ""), + ] { + assert_eq!(basename_windows(input), expected, "{input:?}"); + } + } + + #[test] + fn basename_posix_matches_zig_tests() { + for (input, expected) in [ + ("", ""), + ("/", ""), + ("/dir/basename.ext", "basename.ext"), + ("/basename.ext", "basename.ext"), + ("basename.ext/", "basename.ext"), + ("basename.ext//", "basename.ext"), + ("/aaa/", "aaa"), + ("//a", "a"), + ("\\dir\\basename.ext", "\\dir\\basename.ext"), + ("basename.ext\\\\", "basename.ext\\\\"), + ("1:theme", "1:theme"), + ] { + assert_eq!(basename_posix(input), expected, "{input:?}"); + } + } + #[test] fn zig_basename_matches_std() { assert_eq!(zig_basename(""), ""); diff --git a/crates/ghostty-core/src/global.rs b/crates/ghostty-core/src/global.rs index 7f4a845..ea88a59 100644 --- a/crates/ghostty-core/src/global.rs +++ b/crates/ghostty-core/src/global.rs @@ -204,6 +204,19 @@ fn init_with(opts: InitOptions, default_logging: Logging) -> &'static GlobalStat // SIGPIPE is ignored by the Rust runtime already (it sets SIG_IGN // before main), which is what Zig's `initSignals` does. + // Setup our Xev backend if we're dynamic + if ghostty_os::xev::is_dynamic() + && let Err(err) = ghostty_os::xev::detect() + { + log::warn!( + "failed to detect xev backend, falling back to most compatible backend err={err}" + ); + } + log::info!( + "libxev default backend={}", + ghostty_os::xev::backend().name() + ); + // As early as possible, initialize our resource limits. let rlimits = ResourceLimits::init(); diff --git a/crates/ghostty-os/Cargo.toml b/crates/ghostty-os/Cargo.toml index 12bb7ed..7741175 100644 --- a/crates/ghostty-os/Cargo.toml +++ b/crates/ghostty-os/Cargo.toml @@ -32,6 +32,10 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Threading", ] } +# libxev's `IO_Uring.available()` probe (`os/xev.rs`). +[target.'cfg(target_os = "linux")'.dependencies] +io-uring = "0.7" + [target.'cfg(unix)'.dependencies] libc = "0.2.190" diff --git a/crates/ghostty-os/src/lib.rs b/crates/ghostty-os/src/lib.rs index b39a928..c6d6ab4 100644 --- a/crates/ghostty-os/src/lib.rs +++ b/crates/ghostty-os/src/lib.rs @@ -32,6 +32,7 @@ pub mod file_type; #[cfg(unix)] pub mod flatpak; pub mod osbytes; +pub mod xev; pub use ghostty_zigfmt as zig_fmt; #[cfg(windows)] pub mod flatpak { diff --git a/crates/ghostty-os/src/xev.rs b/crates/ghostty-os/src/xev.rs new file mode 100644 index 0000000..0335688 --- /dev/null +++ b/crates/ghostty-os/src/xev.rs @@ -0,0 +1,214 @@ +//! Runtime selection of the async event-loop backend (libxev's +//! `Dynamic` API: `detect`, `prefer`, `backend`; `Backend.candidates`). +//! +//! On Linux the candidates are `io_uring` then `epoll`; every other platform +//! has exactly one backend (kqueue / IOCP / wasi poll), for which libxev's +//! dynamic API collapses to the static one: nothing can be selected. +//! +//! The selection is process-global, like the `pub var backend` in libxev's +//! `dynamic.zig`. It defaults to the *last* candidate (`epoll`) without +//! testing availability; call [`detect`] at startup (`global.zig`) and +//! optionally [`prefer`] (GTK `Application.startupXev`) before any loop is +//! created. + +use std::sync::atomic::{AtomicU8, Ordering}; + +/// Zig: `xev.Backend`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backend { + IoUring, + Epoll, + Kqueue, + WasiPoll, + Iocp, +} + +impl Backend { + /// `@tagName`. + pub fn name(self) -> &'static str { + match self { + Backend::IoUring => "io_uring", + Backend::Epoll => "epoll", + Backend::Kqueue => "kqueue", + Backend::WasiPoll => "wasi_poll", + Backend::Iocp => "iocp", + } + } + + fn to_u8(self) -> u8 { + self as u8 + } + + fn from_u8(v: u8) -> Backend { + match v { + 0 => Backend::IoUring, + 1 => Backend::Epoll, + 2 => Backend::Kqueue, + 3 => Backend::WasiPoll, + _ => Backend::Iocp, + } + } +} + +impl std::fmt::Display for Backend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +/// Zig: `Backend.candidates()` in priority order. +pub fn candidates() -> &'static [Backend] { + #[cfg(target_os = "linux")] + { + &[Backend::IoUring, Backend::Epoll] + } + #[cfg(target_os = "windows")] + { + &[Backend::Iocp] + } + #[cfg(target_arch = "wasm32")] + { + &[Backend::WasiPoll] + } + #[cfg(not(any(target_os = "linux", target_os = "windows", target_arch = "wasm32")))] + { + &[Backend::Kqueue] + } +} + +/// Zig: `xev.dynamic` (more than one candidate). +pub fn is_dynamic() -> bool { + candidates().len() > 1 +} + +static BACKEND: AtomicU8 = AtomicU8::new(0); +static INIT: std::sync::Once = std::sync::Once::new(); + +fn init_default() { + INIT.call_once(|| { + let last = *candidates().last().unwrap_or(&Backend::Epoll); + BACKEND.store(last.to_u8(), Ordering::SeqCst); + }); +} + +/// The backend in use (Zig: `xev.backend`). +pub fn backend() -> Backend { + init_default(); + Backend::from_u8(BACKEND.load(Ordering::SeqCst)) +} + +/// Zig: `Api().available()`. +pub fn available(be: Backend) -> bool { + match be { + Backend::Epoll => cfg!(target_os = "linux"), + Backend::IoUring => io_uring_available(), + Backend::Kqueue => cfg!(any(target_vendor = "apple", target_os = "freebsd")), + Backend::WasiPoll => cfg!(target_arch = "wasm32"), + Backend::Iocp => cfg!(windows), + } +} + +/// libxev performs a harmless `io_uring_setup(256)` to see if we have +/// io_uring support. We additionally require what our loop needs +/// (`IORING_OP_POLL_ADD`, `IORING_OP_POLL_REMOVE` and the `EXT_ARG` timeout), +/// so that "available" never selects a ring that cannot run the IO loop. +#[cfg(target_os = "linux")] +fn io_uring_available() -> bool { + let Ok(ring) = io_uring::IoUring::new(256) else { + return false; + }; + if !ring.params().is_feature_ext_arg() { + return false; + } + let mut probe = io_uring::Probe::new(); + if ring.submitter().register_probe(&mut probe).is_err() { + return false; + } + probe.is_supported(io_uring::opcode::PollAdd::CODE) + && probe.is_supported(io_uring::opcode::PollRemove::CODE) +} + +#[cfg(not(target_os = "linux"))] +fn io_uring_available() -> bool { + false +} + +/// There is no available backend (Zig: `error.NoAvailableBackends`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("NoAvailableBackends")] +pub struct NoAvailableBackends; + +/// Detect the preferred backend: the first available candidate. +pub fn detect() -> Result<(), NoAvailableBackends> { + init_default(); + for &be in candidates() { + if available(be) { + BACKEND.store(be.to_u8(), Ordering::SeqCst); + return Ok(()); + } + } + Err(NoAvailableBackends) +} + +/// Manually set the backend to use. If the backend is not a candidate or not +/// available the backend in use is unchanged and false is returned. +pub fn prefer(be: Backend) -> bool { + init_default(); + if !candidates().contains(&be) || !available(be) { + return false; + } + BACKEND.store(be.to_u8(), Ordering::SeqCst); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The process-global is shared between tests. + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn names_match_zig_tags() { + assert_eq!(Backend::IoUring.name(), "io_uring"); + assert_eq!(Backend::Epoll.to_string(), "epoll"); + } + + #[test] + fn detect_picks_first_available_candidate() { + let _g = LOCK.lock().unwrap(); + detect().unwrap(); + let expected = candidates() + .iter() + .copied() + .find(|b| available(*b)) + .unwrap(); + assert_eq!(backend(), expected); + } + + #[test] + fn prefer_rejects_non_candidates() { + let _g = LOCK.lock().unwrap(); + detect().unwrap(); + let before = backend(); + assert!(!prefer(Backend::Iocp)); + assert_eq!(backend(), before); + } + + #[cfg(target_os = "linux")] + #[test] + fn prefer_epoll_then_detect() { + let _g = LOCK.lock().unwrap(); + assert!(prefer(Backend::Epoll)); + assert_eq!(backend(), Backend::Epoll); + if available(Backend::IoUring) { + assert!(prefer(Backend::IoUring)); + assert_eq!(backend(), Backend::IoUring); + } else { + let before = backend(); + assert!(!prefer(Backend::IoUring)); + assert_eq!(backend(), before); + } + detect().unwrap(); + } +} diff --git a/crates/ghostty-os/src/zig_error.rs b/crates/ghostty-os/src/zig_error.rs index 22ea482..74d7745 100644 --- a/crates/ghostty-os/src/zig_error.rs +++ b/crates/ghostty-os/src/zig_error.rs @@ -61,63 +61,67 @@ pub fn errno_name(errno: i32) -> &'static str { } /// See the unix variant; this is the Windows (Win32 error code) table. +/// +/// Zig's Windows file opens go through `std.os.windows.OpenFile` +/// (`NtCreateFile`), which switches on the NTSTATUS. Rust opens with +/// `CreateFileW`, whose error is `RtlNtStatusToDosError(status)`, so each arm +/// below is the Win32 code of the NTSTATUS arm named in its comment (codes +/// that `OpenFile` does not name are `error.Unexpected`): +/// +/// | NTSTATUS | Win32 | Zig error | +/// |---|---|---| +/// | `OBJECT_NAME_INVALID` | `ERROR_INVALID_NAME` | `BadPathName` | +/// | `OBJECT_NAME_NOT_FOUND` / `OBJECT_PATH_NOT_FOUND` | `ERROR_FILE_NOT_FOUND` / `ERROR_PATH_NOT_FOUND` | `FileNotFound` | +/// | `BAD_NETWORK_PATH` / `BAD_NETWORK_NAME` | `ERROR_BAD_NETPATH` / `ERROR_BAD_NET_NAME` | `NetworkNotFound` | +/// | `NO_MEDIA_IN_DEVICE` | `ERROR_NOT_READY` | `NoDevice` | +/// | `SHARING_VIOLATION`, `ACCESS_DENIED`, `USER_MAPPED_FILE`, `FILE_IS_A_DIRECTORY` | `ERROR_SHARING_VIOLATION`, `ERROR_ACCESS_DENIED`, `ERROR_USER_MAPPED_FILE` | `AccessDenied` | +/// | `PIPE_BUSY` / `PIPE_NOT_AVAILABLE` | `ERROR_PIPE_BUSY` | `PipeBusy` | +/// | `OBJECT_NAME_COLLISION` | `ERROR_ALREADY_EXISTS` | `PathAlreadyExists` | +/// | `NOT_A_DIRECTORY` | `ERROR_DIRECTORY` | `NotDir` | +/// +/// plus `wToPrefixedFileW`'s `NameTooLong` (`ERROR_FILENAME_EXCED_RANGE`). #[must_use] #[cfg(windows)] -#[allow(non_upper_case_globals)] // windows-sys spells two constants `..._64k` pub fn errno_name(code: i32) -> &'static str { - use windows_sys::Win32::Foundation::{ - ERROR_ACCESS_DENIED, - ERROR_AUTODATASEG_EXCEEDS_64k, - ERROR_BAD_EXE_FORMAT, - ERROR_BAD_FORMAT, - ERROR_COMMITMENT_LIMIT, - ERROR_DIRECTORY, - ERROR_DISK_FULL, - ERROR_DYNLINK_FROM_INVALID_RING, - ERROR_EXE_MACHINE_TYPE_MISMATCH, - ERROR_EXE_MARKED_INVALID, - ERROR_FILE_NOT_FOUND, - ERROR_FILENAME_EXCED_RANGE, - ERROR_INFLOOP_IN_RELOC_CHAIN, - ERROR_INVALID_EXE_SIGNATURE, - ERROR_INVALID_MINALLOCSIZE, - ERROR_INVALID_MODULETYPE, - ERROR_INVALID_NAME, - ERROR_INVALID_SEGDPL, - ERROR_INVALID_STACKSEG, - ERROR_INVALID_STARTING_CODESEG, - ERROR_IOPL_NOT_ENABLED, - ERROR_ITERATED_DATA_EXCEEDS_64k, - ERROR_PATH_NOT_FOUND, - ERROR_RELOC_CHAIN_XEEDS_SEGLIM, - ERROR_RING2SEG_MUST_BE_MOVABLE, - ERROR_SHARING_VIOLATION, - }; - match code as u32 { - ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND | ERROR_DIRECTORY => "FileNotFound", - ERROR_ACCESS_DENIED => "AccessDenied", - ERROR_INVALID_NAME => "InvalidName", + windows_errno_name(code as u32) +} + +/// The Windows table on plain numbers (so it is testable on every host). +#[must_use] +#[cfg_attr(not(windows), allow(dead_code))] +fn windows_errno_name(code: u32) -> &'static str { + // Win32 error codes (winerror.h). Mirrors the NTSTATUS mapping of + // `std.os.windows.OpenFile` in Zig 0.15.2 (verified against its source). + const ERROR_FILE_NOT_FOUND: u32 = 2; + const ERROR_PATH_NOT_FOUND: u32 = 3; + const ERROR_ACCESS_DENIED: u32 = 5; + const ERROR_NOT_READY: u32 = 21; + const ERROR_SHARING_VIOLATION: u32 = 32; + const ERROR_BAD_NETPATH: u32 = 53; + const ERROR_BAD_NET_NAME: u32 = 67; + const ERROR_FILE_EXISTS: u32 = 80; + const ERROR_INVALID_NAME: u32 = 123; + const ERROR_ALREADY_EXISTS: u32 = 183; + const ERROR_FILENAME_EXCED_RANGE: u32 = 206; + const ERROR_VIRUS_INFECTED: u32 = 225; + const ERROR_VIRUS_DELETED: u32 = 226; + const ERROR_PIPE_BUSY: u32 = 231; + const ERROR_DIRECTORY: u32 = 267; + const ERROR_USER_MAPPED_FILE: u32 = 1224; + match code { + ERROR_INVALID_NAME => "BadPathName", + ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => "FileNotFound", + ERROR_BAD_NETPATH | ERROR_BAD_NET_NAME => "NetworkNotFound", + ERROR_NOT_READY => "NoDevice", + // A sharing violation is `AccessDenied` in `OpenFile` (the + // running-executable `FileBusy` retry logic is only in Zig 0.16). + ERROR_SHARING_VIOLATION | ERROR_ACCESS_DENIED | ERROR_USER_MAPPED_FILE => "AccessDenied", + ERROR_PIPE_BUSY => "PipeBusy", + // STATUS_OBJECT_NAME_COLLISION surfaces as either code. + ERROR_FILE_EXISTS | ERROR_ALREADY_EXISTS => "PathAlreadyExists", + ERROR_VIRUS_INFECTED | ERROR_VIRUS_DELETED => "AntivirusInterference", + ERROR_DIRECTORY => "NotDir", ERROR_FILENAME_EXCED_RANGE => "NameTooLong", - ERROR_SHARING_VIOLATION => "FileBusy", - ERROR_COMMITMENT_LIMIT => "SystemResources", - ERROR_DISK_FULL => "NoSpaceLeft", - ERROR_BAD_FORMAT - | ERROR_INVALID_STARTING_CODESEG - | ERROR_INVALID_STACKSEG - | ERROR_INVALID_MODULETYPE - | ERROR_INVALID_EXE_SIGNATURE - | ERROR_EXE_MARKED_INVALID - | ERROR_BAD_EXE_FORMAT - | ERROR_ITERATED_DATA_EXCEEDS_64k - | ERROR_INVALID_MINALLOCSIZE - | ERROR_DYNLINK_FROM_INVALID_RING - | ERROR_IOPL_NOT_ENABLED - | ERROR_INVALID_SEGDPL - | ERROR_AUTODATASEG_EXCEEDS_64k - | ERROR_RING2SEG_MUST_BE_MOVABLE - | ERROR_RELOC_CHAIN_XEEDS_SEGLIM - | ERROR_INFLOOP_IN_RELOC_CHAIN - | ERROR_EXE_MACHINE_TYPE_MISMATCH => "InvalidExe", _ => "Unexpected", } } @@ -209,25 +213,38 @@ mod tests { assert_eq!(errno_name(40), "Unexpected"); } - #[cfg(windows)] #[test] fn windows_table() { for (code, name) in [ - (2, "FileNotFound"), + (2u32, "FileNotFound"), (3, "FileNotFound"), (5, "AccessDenied"), - (32, "FileBusy"), - (112, "NoSpaceLeft"), - (123, "InvalidName"), - (193, "InvalidExe"), + // ERROR_SHARING_VIOLATION: AccessDenied like OpenFile. + (32, "AccessDenied"), + (1224, "AccessDenied"), + (21, "NoDevice"), + (53, "NetworkNotFound"), + (67, "NetworkNotFound"), + (123, "BadPathName"), + (183, "PathAlreadyExists"), + (80, "PathAlreadyExists"), + (225, "AntivirusInterference"), + (226, "AntivirusInterference"), (206, "NameTooLong"), - (267, "FileNotFound"), - (1455, "SystemResources"), + (231, "PipeBusy"), + (267, "NotDir"), (87, "Unexpected"), + (112, "Unexpected"), + (193, "Unexpected"), ] { - assert_eq!(errno_name(code), name); + assert_eq!(windows_errno_name(code), name, "code {code}"); } - let e = std::io::Error::from_raw_os_error(5); + } + + #[cfg(windows)] + #[test] + fn windows_io_error_name() { + let e = std::io::Error::from_raw_os_error(32); assert_eq!(io_error_name(&e), "error.AccessDenied"); } } diff --git a/crates/ghostty-termio/Cargo.toml b/crates/ghostty-termio/Cargo.toml index 2f2a00e..0a5c990 100644 --- a/crates/ghostty-termio/Cargo.toml +++ b/crates/ghostty-termio/Cargo.toml @@ -18,6 +18,10 @@ ghostty-vt.workspace = true log = "0.4.34" thiserror = "2.0.21" +# io_uring backend of the IO thread's reactor (libxev `IO_Uring`). +[target.'cfg(target_os = "linux")'.dependencies] +io-uring = "0.7" + [target.'cfg(unix)'.dependencies] libc = "0.2.190" nix = { version = "0.31.3", features = ["signal"] } diff --git a/crates/ghostty-termio/src/lib.rs b/crates/ghostty-termio/src/lib.rs index c77cfbc..201239d 100644 --- a/crates/ghostty-termio/src/lib.rs +++ b/crates/ghostty-termio/src/lib.rs @@ -29,6 +29,8 @@ pub mod message; pub mod options; #[cfg_attr(windows, path = "pty_windows.rs")] pub mod pty; +#[cfg(unix)] +pub mod reactor; pub mod shell_integration; pub mod sinks; pub mod state; diff --git a/crates/ghostty-termio/src/reactor.rs b/crates/ghostty-termio/src/reactor.rs new file mode 100644 index 0000000..3bfc96a --- /dev/null +++ b/crates/ghostty-termio/src/reactor.rs @@ -0,0 +1,553 @@ +//! The IO thread's event demultiplexer (the part of libxev's `Loop` the IO +//! thread uses: fd readiness plus a timeout). +//! +//! libxev selects its backend at runtime (`ghostty_os::xev`, set from the +//! `async-backend` config in the GTK application): `io_uring` or `epoll` on +//! Linux. This mirrors that choice: +//! +//! * [`Backend::IoUring`]: one `IORING_OP_POLL_ADD` per interest (re-armed +//! after it fires, removed with `IORING_OP_POLL_REMOVE` when the interest +//! goes away) and an `EXT_ARG` timeout on the wait. +//! * [`Backend::Epoll`]: a persistent epoll instance whose registrations are +//! diffed against the interests every wait (level-triggered). +//! * `Poll`: plain `poll(2)`, used where libxev has a single, different +//! backend (kqueue on macOS) and cannot be selected. + +#[cfg(target_os = "linux")] +use std::{collections::HashMap, os::fd::RawFd}; +use std::{io, os::fd::BorrowedFd, time::Duration}; + +pub use ghostty_os::xev::Backend; +use rustix::event::{PollFd, PollFlags}; + +/// One thing to wait for: readability (or writability) of `fd`. `token` is +/// reported back by [`Reactor::wait`] and must be unique per call. The fd +/// must stay open while its token is passed to `wait`; when it is closed the +/// token must be left out of one `wait` before it is reused with another fd. +#[derive(Clone, Copy)] +pub struct Interest<'a> { + pub token: usize, + pub fd: BorrowedFd<'a>, + pub write: bool, +} + +/// See the module docs. +pub enum Reactor { + Poll, + #[cfg(target_os = "linux")] + Epoll(EpollReactor), + #[cfg(target_os = "linux")] + IoUring(Box), +} + +impl Reactor { + /// Create the reactor for `backend`. Backends that do not exist for this + /// platform use `poll(2)`. + pub fn new(backend: Backend) -> io::Result { + match backend { + #[cfg(target_os = "linux")] + Backend::Epoll => Ok(Reactor::Epoll(EpollReactor::new()?)), + #[cfg(target_os = "linux")] + Backend::IoUring => Ok(Reactor::IoUring(Box::new(UringReactor::new()?))), + _ => Ok(Reactor::Poll), + } + } + + /// A plain `poll(2)` reactor regardless of platform. + pub fn poll() -> Reactor { + Reactor::Poll + } + + /// The name used in logs. + pub fn name(&self) -> &'static str { + match self { + Reactor::Poll => "poll", + #[cfg(target_os = "linux")] + Reactor::Epoll(_) => "epoll", + #[cfg(target_os = "linux")] + Reactor::IoUring(_) => "io_uring", + } + } + + /// Wait until at least one interest is ready or `timeout` elapses + /// (`None` blocks forever). The tokens of the ready interests are + /// appended to `ready` (which is cleared first). An interrupted wait + /// returns with nothing ready. + pub fn wait( + &mut self, + interests: &[Interest<'_>], + timeout: Option, + ready: &mut Vec, + ) -> io::Result<()> { + ready.clear(); + match self { + Reactor::Poll => poll_wait(interests, timeout, ready), + #[cfg(target_os = "linux")] + Reactor::Epoll(r) => r.wait(interests, timeout, ready), + #[cfg(target_os = "linux")] + Reactor::IoUring(r) => r.wait(interests, timeout, ready), + } + } +} + +fn timespec(d: Duration) -> rustix::time::Timespec { + rustix::time::Timespec { + tv_sec: d.as_secs() as _, + tv_nsec: i64::from(d.subsec_nanos()) as _, + } +} + +fn poll_wait( + interests: &[Interest<'_>], + timeout: Option, + ready: &mut Vec, +) -> io::Result<()> { + let ts = timeout.map(timespec); + let mut fds: Vec> = interests + .iter() + .map(|i| { + PollFd::from_borrowed_fd( + i.fd, + if i.write { + PollFlags::OUT + } else { + PollFlags::IN + }, + ) + }) + .collect(); + match rustix::event::poll(&mut fds, ts.as_ref()) { + Ok(_) => {} + Err(rustix::io::Errno::INTR) => return Ok(()), + Err(err) => return Err(err.into()), + } + for (i, fd) in interests.iter().zip(&fds) { + if !fd.revents().is_empty() { + ready.push(i.token); + } + } + Ok(()) +} + +/// Persistent epoll instance (level-triggered). +#[cfg(target_os = "linux")] +pub struct EpollReactor { + epoll: std::os::fd::OwnedFd, + /// token -> (raw fd, write) + registered: HashMap, +} + +#[cfg(target_os = "linux")] +impl EpollReactor { + fn new() -> io::Result { + use rustix::event::epoll; + Ok(EpollReactor { + epoll: epoll::create(epoll::CreateFlags::CLOEXEC)?, + registered: HashMap::new(), + }) + } + + #[allow(unsafe_code)] + fn sync(&mut self, interests: &[Interest<'_>]) -> io::Result<()> { + use rustix::event::epoll; + + // Drop registrations that are gone or changed. + let stale: Vec = self + .registered + .iter() + .filter(|(token, (raw, write))| { + !interests.iter().any(|i| { + i.token == **token + && std::os::fd::AsRawFd::as_raw_fd(&i.fd) == *raw + && i.write == *write + }) + }) + .map(|(t, _)| *t) + .collect(); + for token in stale { + if let Some((raw, _)) = self.registered.remove(&token) { + // SAFETY: the fd may already have been closed by its owner; + // `epoll_ctl(DEL)` then fails with EBADF/ENOENT, which is + // ignored. The borrow does not outlive this call and nothing + // is read from or written to the fd. + let fd = unsafe { BorrowedFd::borrow_raw(raw) }; + let _ = epoll::delete(&self.epoll, fd); + } + } + + for i in interests { + if self.registered.contains_key(&i.token) { + continue; + } + let flags = if i.write { + epoll::EventFlags::OUT + } else { + epoll::EventFlags::IN + }; + epoll::add( + &self.epoll, + i.fd, + epoll::EventData::new_u64(i.token as u64), + flags, + )?; + self.registered + .insert(i.token, (std::os::fd::AsRawFd::as_raw_fd(&i.fd), i.write)); + } + Ok(()) + } + + fn wait( + &mut self, + interests: &[Interest<'_>], + timeout: Option, + ready: &mut Vec, + ) -> io::Result<()> { + use rustix::event::epoll; + + self.sync(interests)?; + // (`Event` is not `Send`, so it cannot live in the reactor.) + let mut events: Vec = Vec::with_capacity(16); + match epoll::wait( + &self.epoll, + rustix::buffer::spare_capacity(&mut events), + timeout.map(timespec).as_ref(), + ) { + Ok(_) => {} + Err(rustix::io::Errno::INTR) => return Ok(()), + Err(err) => return Err(err.into()), + } + for ev in &events { + let token = ev.data.u64() as usize; + if self.registered.contains_key(&token) { + ready.push(token); + } + } + Ok(()) + } +} + +/// The user_data of `IORING_OP_POLL_REMOVE` submissions (ids start at 1). +#[cfg(target_os = "linux")] +const REMOVE_ID: u64 = 0; + +/// io_uring based poller (`IORING_OP_POLL_ADD` one-shot polls). +#[cfg(target_os = "linux")] +pub struct UringReactor { + ring: io_uring::IoUring, + /// token -> (raw fd, write, id of the armed poll) + armed: HashMap, + next_id: u64, +} + +#[cfg(target_os = "linux")] +impl UringReactor { + fn new() -> io::Result { + // Same ring size as libxev's default. + Ok(UringReactor { + ring: io_uring::IoUring::new(256)?, + armed: HashMap::new(), + next_id: 1, + }) + } + + /// Queue an entry, flushing the submission queue first if it is full. + #[allow(unsafe_code)] + fn push(&mut self, entry: &io_uring::squeue::Entry) -> io::Result<()> { + // SAFETY: poll add/remove entries reference no user memory: the fd + // is a plain descriptor number that is valid for the duration of the + // `wait` call that submits it (the caller borrows it), and the + // user_data is an opaque id. + unsafe { + if self.ring.submission().push(entry).is_err() { + self.ring.submit()?; + self.ring + .submission() + .push(entry) + .map_err(|_| io::Error::other("io_uring submission queue full"))?; + } + } + Ok(()) + } + + fn sync(&mut self, interests: &[Interest<'_>]) -> io::Result<()> { + use io_uring::{opcode, types}; + + // Cancel polls whose interest is gone or changed. + let stale: Vec = self + .armed + .iter() + .filter(|(token, (raw, write, _))| { + !interests.iter().any(|i| { + i.token == **token + && std::os::fd::AsRawFd::as_raw_fd(&i.fd) == *raw + && i.write == *write + }) + }) + .map(|(t, _)| *t) + .collect(); + for token in stale { + if let Some((_, _, id)) = self.armed.remove(&token) { + let entry = opcode::PollRemove::new(id).build().user_data(REMOVE_ID); + self.push(&entry)?; + } + } + + // Arm the new ones. + for i in interests { + if self.armed.contains_key(&i.token) { + continue; + } + let raw = std::os::fd::AsRawFd::as_raw_fd(&i.fd); + let mask = if i.write { libc::POLLOUT } else { libc::POLLIN } as u32; + let id = self.next_id; + self.next_id += 1; + let entry = opcode::PollAdd::new(types::Fd(raw), mask) + .build() + .user_data(id); + self.push(&entry)?; + self.armed.insert(i.token, (raw, i.write, id)); + } + Ok(()) + } + + fn reap(&mut self, interests: &[Interest<'_>], ready: &mut Vec) { + let mut cq = self.ring.completion(); + cq.sync(); + for cqe in &mut cq { + let id = cqe.user_data(); + if id == REMOVE_ID { + continue; + } + let Some(token) = self + .armed + .iter() + .find(|(_, (_, _, armed_id))| *armed_id == id) + .map(|(t, _)| *t) + else { + // Completion of a poll we already cancelled. + continue; + }; + // Polls are one-shot: it must be armed again next time. + self.armed.remove(&token); + if interests.iter().any(|i| i.token == token) { + ready.push(token); + } + } + } + + fn wait( + &mut self, + interests: &[Interest<'_>], + timeout: Option, + ready: &mut Vec, + ) -> io::Result<()> { + use io_uring::types; + + self.sync(interests)?; + + // Results already waiting (e.g. from a previous partial reap). + let res = match timeout { + None => self.ring.submit_and_wait(1), + Some(d) => { + let ts = types::Timespec::new() + .sec(d.as_secs()) + .nsec(d.subsec_nanos()); + let args = types::SubmitArgs::new().timespec(&ts); + self.ring.submitter().submit_with_args(1, &args) + } + }; + match res { + Ok(_) => {} + Err(e) => match e.raw_os_error() { + Some(libc::ETIME | libc::EINTR | libc::EBUSY) => {} + _ => return Err(e), + }, + } + self.reap(interests, ready); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::os::fd::{AsFd, OwnedFd}; + + use super::*; + + fn backends() -> Vec { + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] + let mut v = vec![Backend::Kqueue]; // -> poll + #[cfg(target_os = "linux")] + { + v.push(Backend::Epoll); + if ghostty_os::xev::available(Backend::IoUring) { + v.push(Backend::IoUring); + } else { + eprintln!("SKIP io_uring reactor: unavailable"); + } + } + v + } + + fn pipe() -> (OwnedFd, OwnedFd) { + rustix::pipe::pipe().unwrap() + } + + #[test] + fn times_out_with_nothing_ready() { + for be in backends() { + let mut r = Reactor::new(be).unwrap(); + let (rd, _wr) = pipe(); + let mut ready = vec![]; + let start = std::time::Instant::now(); + r.wait( + &[Interest { + token: 7, + fd: rd.as_fd(), + write: false, + }], + Some(Duration::from_millis(30)), + &mut ready, + ) + .unwrap(); + assert!(ready.is_empty(), "{}", r.name()); + assert!(start.elapsed() >= Duration::from_millis(25), "{}", r.name()); + } + } + + #[test] + fn readable_write_and_rearm() { + for be in backends() { + let mut r = Reactor::new(be).unwrap(); + let (rd, wr) = pipe(); + let ints = [ + Interest { + token: 1, + fd: rd.as_fd(), + write: false, + }, + Interest { + token: 2, + fd: wr.as_fd(), + write: true, + }, + ]; + let mut ready = vec![]; + // The write end is writable immediately; the read end is not. + r.wait(&ints, Some(Duration::from_millis(500)), &mut ready) + .unwrap(); + assert_eq!(ready, vec![2], "{}", r.name()); + + rustix::io::write(&wr, b"x").unwrap(); + // Level-triggered: still reported on every wait until drained. + for _ in 0..3 { + r.wait(&ints[..1], Some(Duration::from_millis(500)), &mut ready) + .unwrap(); + assert_eq!(ready, vec![1], "{}", r.name()); + } + let mut buf = [0u8; 8]; + rustix::io::read(&rd, &mut buf).unwrap(); + r.wait(&ints[..1], Some(Duration::from_millis(20)), &mut ready) + .unwrap(); + assert!(ready.is_empty(), "{}", r.name()); + } + } + + #[test] + fn dropped_interest_is_not_reported_and_can_return() { + for be in backends() { + let mut r = Reactor::new(be).unwrap(); + let (rd, wr) = pipe(); + let (rd2, wr2) = pipe(); + let both = [ + Interest { + token: 1, + fd: rd.as_fd(), + write: false, + }, + Interest { + token: 2, + fd: rd2.as_fd(), + write: false, + }, + ]; + let mut ready = vec![]; + r.wait(&both, Some(Duration::from_millis(10)), &mut ready) + .unwrap(); + rustix::io::write(&wr, b"x").unwrap(); + rustix::io::write(&wr2, b"y").unwrap(); + // Only token 2 is wanted now. + r.wait(&both[1..], Some(Duration::from_millis(500)), &mut ready) + .unwrap(); + assert_eq!(ready, vec![2], "{}", r.name()); + // Token 1 comes back and is still readable. + r.wait(&both[..1], Some(Duration::from_millis(500)), &mut ready) + .unwrap(); + assert_eq!(ready, vec![1], "{}", r.name()); + } + } + + #[test] + fn wakes_from_another_thread() { + for be in backends() { + let mut r = Reactor::new(be).unwrap(); + let (rd, wr) = pipe(); + let h = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(30)); + rustix::io::write(&wr, b"x").unwrap(); + wr + }); + let mut ready = vec![]; + r.wait( + &[Interest { + token: 3, + fd: rd.as_fd(), + write: false, + }], + None, + &mut ready, + ) + .unwrap(); + assert_eq!(ready, vec![3], "{}", r.name()); + drop(h.join().unwrap()); + } + } + + #[test] + fn closed_fd_registration_is_replaced() { + for be in backends() { + let mut r = Reactor::new(be).unwrap(); + let (rd, wr) = pipe(); + let mut ready = vec![]; + r.wait( + &[Interest { + token: 1, + fd: rd.as_fd(), + write: false, + }], + Some(Duration::from_millis(5)), + &mut ready, + ) + .unwrap(); + drop(rd); + drop(wr); + // Contract: a token whose fd was closed is omitted from one wait + // before it may be used with a new fd. + r.wait(&[], Some(Duration::from_millis(1)), &mut ready) + .unwrap(); + let (rd, wr) = pipe(); + rustix::io::write(&wr, b"x").unwrap(); + r.wait( + &[Interest { + token: 1, + fd: rd.as_fd(), + write: false, + }], + Some(Duration::from_millis(500)), + &mut ready, + ) + .unwrap(); + assert_eq!(ready, vec![1], "{}", r.name()); + } + } +} diff --git a/crates/ghostty-termio/src/thread.rs b/crates/ghostty-termio/src/thread.rs index efd12bd..e8662f3 100644 --- a/crates/ghostty-termio/src/thread.rs +++ b/crates/ghostty-termio/src/thread.rs @@ -12,7 +12,8 @@ //! //! # Event loop //! -//! libxev is replaced by a single `poll(2)` loop over: the mailbox wakeup +//! libxev is replaced by a single readiness loop (see [`crate::reactor`]: +//! `io_uring`, `epoll` or `poll(2)` as selected by `async-backend`) over: the mailbox wakeup //! eventfd, the stop eventfd, the child's pidfd (or the Flatpak exit //! wakeup), the pty (`POLLOUT`, only while writes are queued) and a set of //! deadlines (selection scroll, resize coalescing, synchronized-output reset, @@ -27,9 +28,9 @@ 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}; +#[cfg(unix)] +use crate::reactor::{Backend, Interest, Reactor}; use crate::{ backend::ThreadData as BackendThreadData, message::Message, @@ -43,6 +44,17 @@ use crate::{ #[path = "thread_windows.rs"] mod windows_loop; +#[cfg(unix)] +const TOKEN_MAILBOX: usize = 0; +#[cfg(unix)] +const TOKEN_STOP: usize = 1; +#[cfg(unix)] +const TOKEN_PROCESS: usize = 2; +#[cfg(unix)] +const TOKEN_FLATPAK: usize = 3; +#[cfg(unix)] +const TOKEN_PTY_OUT: usize = 4; + /// The number of milliseconds to coalesce certain messages like resize for. /// Not all message types are coalesced. pub const COALESCE_MIN_MS: u64 = 25; @@ -87,6 +99,10 @@ pub struct Thread { flags: Flags, + /// The event demultiplexer (the xev loop). Taken out while running. + #[cfg(unix)] + reactor: Option, + /// The surface dimensions recorded in crash reports. pub crash_dims: Option>, } @@ -96,7 +112,31 @@ impl Thread { /// up all the internal state necessary prior to starting the thread. It /// is up to the caller to run [`Thread::thread_main`] on a thread. pub fn new() -> io::Result { + #[cfg(unix)] + { + Thread::with_backend(ghostty_os::xev::backend()) + } + #[cfg(not(unix))] + { + Thread::new_() + } + } + + /// Like [`Thread::new`] but with an explicit event loop backend instead + /// of the process-wide selection (`ghostty_os::xev`). + #[cfg(unix)] + pub fn with_backend(backend: Backend) -> io::Result { + let reactor = Reactor::new(backend)?; + log::debug!("io thread event loop backend={}", reactor.name()); + let mut thread = Thread::new_()?; + thread.reactor = Some(reactor); + Ok(thread) + } + + fn new_() -> io::Result { Ok(Thread { + #[cfg(unix)] + reactor: None, stop: Wakeup::new()?, scroll_active: false, scroll_deadline: None, @@ -156,30 +196,37 @@ impl Thread { #[cfg(unix)] fn drain_until_stop(&mut self, io: &Termio) { let mailbox = io.mailbox().clone(); + let Some(mut reactor) = self.reactor.take() else { + return; + }; + let mut ready = Vec::new(); loop { - let mut fds = [ - PollFd::from_borrowed_fd(mailbox.wakeup().as_fd(), PollFlags::IN), - PollFd::from_borrowed_fd(self.stop.as_fd(), PollFlags::IN), + let interests = [ + Interest { + token: TOKEN_MAILBOX, + fd: mailbox.wakeup().as_fd(), + write: false, + }, + Interest { + token: TOKEN_STOP, + fd: self.stop.as_fd(), + write: false, + }, ]; - match rustix::event::poll(&mut fds, None) { - Ok(_) => {} - Err(rustix::io::Errno::INTR) => continue, - Err(err) => { - log::error!("failed to poll for draining err={err}"); - return; - } + if let Err(err) = reactor.wait(&interests, None, &mut ready) { + log::error!("failed to poll for draining err={err}"); + break; } - let stop = fds[1].revents().contains(PollFlags::IN); - let msgs = fds[0].revents().contains(PollFlags::IN); - if msgs { + if ready.contains(&TOKEN_MAILBOX) { mailbox.wakeup().drain(); while mailbox.pop().is_some() {} } - if stop { + if ready.contains(&TOKEN_STOP) { self.stop.drain(); - return; + break; } } + self.reactor = Some(reactor); } fn next_deadline(&self, td: &ThreadData) -> Option { @@ -199,72 +246,86 @@ impl Thread { /// The event loop. Returns when stopped. #[cfg(unix)] fn run(&mut self, io: &mut Termio, td: &mut ThreadData) { + let Some(mut reactor) = self.reactor.take() else { + log::error!("io thread has no event loop"); + return; + }; + self.run_loop(&mut reactor, io, td); + self.reactor = Some(reactor); + } + + #[cfg(unix)] + fn run_loop(&mut self, reactor: &mut Reactor, io: &mut Termio, td: &mut ThreadData) { let mailbox = io.mailbox().clone(); + let mut ready: Vec = Vec::new(); loop { - let timeout = self.next_deadline(td).map(|d| { - let dur = d.saturating_duration_since(Instant::now()); - rustix::time::Timespec { - tv_sec: dur.as_secs() as _, - tv_nsec: i64::from(dur.subsec_nanos()) as _, - } - }); + let timeout = self + .next_deadline(td) + .map(|d| d.saturating_duration_since(Instant::now())); - // Build the poll set: fixed entries first, optional ones after. - let mut fds: Vec> = Vec::with_capacity(5); - fds.push(PollFd::from_borrowed_fd( - mailbox.wakeup().as_fd(), - PollFlags::IN, - )); - fds.push(PollFd::from_borrowed_fd(self.stop.as_fd(), PollFlags::IN)); + // Build the interest set: fixed entries first, optional ones after. + let mut interests: Vec> = Vec::with_capacity(5); + interests.push(Interest { + token: TOKEN_MAILBOX, + fd: mailbox.wakeup().as_fd(), + write: false, + }); + interests.push(Interest { + token: TOKEN_STOP, + fd: self.stop.as_fd(), + write: false, + }); let exec = td.backend.as_exec(); - let proc_idx = exec.process_fd().map(|fd| { - fds.push(PollFd::from_borrowed_fd(fd, PollFlags::IN)); - fds.len() - 1 - }); - let fp_idx = exec.flatpak_fd().map(|fd| { - fds.push(PollFd::from_borrowed_fd(fd, PollFlags::IN)); - fds.len() - 1 - }); - let out_idx = exec.wants_write().then(|| { - fds.push(PollFd::from_borrowed_fd(exec.pty_fd(), PollFlags::OUT)); - fds.len() - 1 - }); - - match rustix::event::poll(&mut fds, timeout.as_ref()) { - Ok(_) => {} - Err(rustix::io::Errno::INTR) => continue, - Err(err) => { - log::error!("io thread poll failed err={err}"); - return; - } + if let Some(fd) = exec.process_fd() { + interests.push(Interest { + token: TOKEN_PROCESS, + fd, + write: false, + }); + } + if let Some(fd) = exec.flatpak_fd() { + interests.push(Interest { + token: TOKEN_FLATPAK, + fd, + write: false, + }); + } + if exec.wants_write() { + interests.push(Interest { + token: TOKEN_PTY_OUT, + fd: exec.pty_fd(), + write: true, + }); } - let rev: Vec = fds.iter().map(PollFd::revents).collect(); - drop(fds); - let ready = |i: usize| !rev[i].is_empty(); + if let Err(err) = reactor.wait(&interests, timeout, &mut ready) { + log::error!("io thread poll failed err={err}"); + return; + } + drop(interests); // Stop requested. - if ready(1) { + if ready.contains(&TOKEN_STOP) { self.stop.drain(); return; } // The mailbox has messages. - if ready(0) { + if ready.contains(&TOKEN_MAILBOX) { // When we wake up, we check the mailbox. Mailbox producers // should wake up our thread after publishing. mailbox.wakeup().drain(); self.drain_mailbox(io, td); } - if proc_idx.is_some_and(ready) { + if ready.contains(&TOKEN_PROCESS) { td.backend.as_exec_mut().on_process_ready(None); } - if fp_idx.is_some_and(ready) { + if ready.contains(&TOKEN_FLATPAK) { td.backend.as_exec_mut().on_flatpak_ready(); } - if out_idx.is_some_and(ready) { + if ready.contains(&TOKEN_PTY_OUT) { td.backend.as_exec_mut().flush_writes(); } diff --git a/crates/ghostty-termio/tests/exec_integration.rs b/crates/ghostty-termio/tests/exec_integration.rs index 490c8f8..bc682d9 100644 --- a/crates/ghostty-termio/tests/exec_integration.rs +++ b/crates/ghostty-termio/tests/exec_integration.rs @@ -3,608 +3,24 @@ //! End-to-end tests: real child processes on a real pty, driven through //! `Termio` + `Thread` (IO thread) + the pty read thread, against a real //! `Terminal`. +//! +//! The whole suite (`exec_integration/suite.rs`) runs once per IO thread +//! event loop backend (`async-backend`): `epoll`, `io_uring` (skipped, with a +//! message, where the kernel/sandbox denies it) and the portable `poll(2)` +//! loop used where libxev has a single different backend (kqueue). -use std::{ - sync::{ - Arc, - Mutex, - atomic::{AtomicUsize, Ordering}, - }, - thread::JoinHandle, - time::{Duration, Instant}, -}; - -use crossbeam_channel::{Receiver, Sender, unbounded}; -use ghostty_apprt::surface::Message as SurfaceMessage; -use ghostty_config::{ - command::Command as ConfigCommand, - config::Config, - io::ReadableIO, - types_b::{LinuxCgroup, ShellIntegration}, -}; -use ghostty_datastruct::blocking_queue::Timeout; -use ghostty_renderer::{ - message::Message as RendererMessage, - size::{CellSize, Padding, ScreenSize, Size}, -}; -use ghostty_termio::{ - Mailbox, - Options, - RendererMailbox, - SharedState, - SharedStateExt, - SurfaceMailbox, - Termio, - Thread, - Wakeup, - backend::{self, Backend}, - exec, - message::Message, - termio::DerivedConfig, -}; - -struct SurfaceRec(Sender); - -impl SurfaceMailbox for SurfaceRec { - fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize { - let _ = self.0.send(msg); - 1 - } -} - -#[derive(Default)] -struct RendererRec { - resizes: Mutex>, - reset_blinks: AtomicUsize, -} - -impl RendererMailbox for RendererRec { - fn push(&self, msg: RendererMessage, _t: Timeout) -> usize { - match msg { - RendererMessage::Resize(s) => self.resizes.lock().unwrap().push(s), - RendererMessage::ResetCursorBlink => { - self.reset_blinks.fetch_add(1, Ordering::SeqCst); - } - _ => {} +macro_rules! suite { + ($name:ident, $be:expr) => { + mod $name { + const UNDER_TEST: ghostty_os::xev::Backend = $be; + include!("exec_integration/suite.rs"); } - 1 - } + }; } -fn size(cols: u32, rows: u32) -> Size { - Size { - screen: ScreenSize { - width: cols * 10, - height: rows * 20, - }, - cell: CellSize { - width: 10, - height: 20, - }, - padding: Padding::default(), - } -} - -struct Fixture { - state: SharedState, - mailbox: Mailbox, - stop: Wakeup, - thread: Option>, - surface: Receiver, - renderer: Arc, - renderer_wakeup: Wakeup, -} - -fn base_config() -> Config { - Config::default() -} - -fn start(command: ConfigCommand, tweak: impl FnOnce(&mut Config)) -> Fixture { - let mut config = base_config(); - tweak(&mut config); - - let mut ecfg = exec::Config::new( - ghostty_os::env::get_env_map(), - ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"), - ); - ecfg.command = Some(command); - ecfg.shell_integration = ShellIntegration::None; - // The default config has `linux-cgroup = single-instance` + - // `gtk-single-instance = detect` which only makes sense after the apprt - // resolved it. - ecfg.rt_pre_exec_info.linux_cgroup = LinuxCgroup::Never; - ecfg.rt_post_fork_info.linux_cgroup = LinuxCgroup::Never; - - let mailbox = Mailbox::new().unwrap(); - let (tx, surface) = unbounded(); - let renderer = Arc::new(RendererRec::default()); - let renderer_wakeup = Wakeup::new().unwrap(); - let termio = Termio::init(Options { - size: size(80, 30), - full_config: &config, - config: DerivedConfig::new(&config), - backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(), - mailbox: mailbox.clone(), - renderer_wakeup: renderer_wakeup.clone(), - renderer_mailbox: renderer.clone(), - surface_mailbox: Arc::new(SurfaceRec(tx)), - }) - .unwrap(); - let state = Arc::clone(termio.renderer_state()); - - let thread = Thread::new().unwrap(); - let stop = thread.stop_handle(); - let handle = std::thread::Builder::new() - .name("io".into()) - .spawn(move || thread.thread_main(termio)) - .unwrap(); - - Fixture { - state, - mailbox, - stop, - thread: Some(handle), - surface, - renderer, - renderer_wakeup, - } -} - -fn sh(script: &str) -> ConfigCommand { - ConfigCommand::Direct(vec!["/bin/sh".into(), "-c".into(), script.into()]) -} - -impl Fixture { - fn text(&self) -> String { - self.state.lock_terminal().plain_string().unwrap() - } - - fn send(&self, msg: Message) { - self.mailbox.send(msg); - self.mailbox.notify(); - } - - fn write(&self, data: &[u8]) { - self.send(Message::write_req(data)); - } - - /// Wait until `f` returns Some or time out (panics). - fn wait_for(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option) -> T { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - if let Some(v) = f(self) { - return v; - } - assert!( - Instant::now() < deadline, - "timed out waiting for {what}; terminal text = {:?}", - self.text() - ); - std::thread::sleep(Duration::from_millis(10)); - } - } - - fn wait_text(&self, needle: &str) { - self.wait_for(&format!("text {needle:?}"), |f| { - f.text().contains(needle).then_some(()) - }); - } - - /// Wait for the ChildExited surface message, returning all messages - /// received up to and including it. - fn wait_exit(&self) -> (u32, u64, Vec) { - let deadline = Instant::now() + Duration::from_secs(10); - let mut seen = Vec::new(); - loop { - let left = deadline.saturating_duration_since(Instant::now()); - let msg = self - .surface - .recv_timeout(left) - .unwrap_or_else(|_| panic!("timed out waiting for child exit; seen={seen:?}")); - if let SurfaceMessage::ChildExited(c) = msg { - return (c.exit_code, c.runtime_ms, seen); - } - seen.push(msg); - } - } - - fn stop(&mut self) { - self.stop.notify().unwrap(); - if let Some(t) = self.thread.take() { - t.join().unwrap(); - } - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = self.stop.notify(); - if let Some(t) = self.thread.take() { - let _ = t.join(); - } - } -} - -#[test] -fn printf_output_reaches_terminal_and_exit_is_reported() { - let mut f = start(sh("printf 'hello\\033[31mred'"), |_| {}); - let (code, runtime_ms, _) = f.wait_exit(); - assert_eq!(code, 0); - assert!(runtime_ms < 10_000); - - // The reader thread may still be draining the pty after the exit event. - f.wait_text("hellored"); - assert_eq!("hellored", f.text()); - - // The SGR was applied: the cursor pen is red (palette 1). - { - let t = f.state.lock_terminal(); - let style = t.screen().cursor.style; - assert!( - !matches!(style.fg_color, ghostty_terminal::style::Color::None), - "pen fg should be set after \\e[31m: {style:?}" - ); - assert_eq!(t.screen().cursor.x, 8); - } - // The renderer was woken up and told to reset the cursor blink. - assert!(f.renderer.reset_blinks.load(Ordering::SeqCst) >= 1); - assert!(f.renderer_wakeup_pending()); - f.stop(); -} - -impl Fixture { - fn renderer_wakeup_pending(&self) -> bool { - self.renderer_wakeup.drain() - } -} - -#[test] -fn nonzero_exit_code_is_reported() { - let mut f = start(sh("exit 3"), |_| {}); - let (code, _, _) = f.wait_exit(); - assert_eq!(code, 3); - f.stop(); -} - -#[test] -fn killed_by_signal_reports_signal_number() { - let mut f = start(sh("kill -KILL $$"), |_| {}); - let (code, _, _) = f.wait_exit(); - assert_eq!(code, 9); - f.stop(); -} - -#[test] -fn exec_failure_exits_quickly_with_error() { - // Abnormal exit detection itself lives in the Surface (it compares - // `runtime_ms` against `abnormal-command-exit-runtime`); termio must - // report a tiny runtime and the exit code. - let mut f = start( - ConfigCommand::Direct(vec!["/nonexistent/definitely-not-here".into()]), - |_| {}, - ); - let (code, runtime_ms, _) = f.wait_exit(); - assert_eq!(code, 1); - assert!(runtime_ms < 5_000, "runtime {runtime_ms}"); - f.stop(); -} - -#[test] -fn writes_reach_the_child_and_are_echoed() { - let mut f = start(sh("cat"), |_| {}); - f.write(b"abc\n"); - // Once by the tty echo and once by cat. - f.wait_for("two echoes", |f| { - (f.text().matches("abc").count() >= 2).then_some(()) - }); - f.stop(); -} - -#[test] -fn large_write_with_backpressure_is_delivered_in_order() { - // 256 KiB through a raw-mode `cat`: exercises the non-blocking write - // queue (the pty buffer is far smaller) and the output reader at the - // same time (cat would otherwise block on a full pty). - let mut f = start( - sh("stty raw -echo; echo ready; head -c 262144 | wc -c; echo done"), - |_| {}, - ); - // Writing before `stty raw` applies would go through the cooked-mode - // line discipline, which silently drops bytes past MAX_CANON (4095) - // on a line without a newline. - f.wait_text("ready"); - let chunk = vec![b'x'; 4096]; - for _ in 0..64 { - f.write(&chunk); - } - f.wait_text("262144"); - f.wait_text("done"); - f.stop(); -} - -#[test] -fn linefeed_mode_translates_cr() { - let mut f = start(sh("stty raw -echo; cat -v"), |_| {}); - // Give stty a moment so the first byte is not eaten by cooked mode. - std::thread::sleep(Duration::from_millis(300)); - f.send(Message::LinefeedMode(true)); - f.write(b"a\r"); - f.wait_text("a^M"); - // cat -v got "a\r\n", so it printed a newline after the ^M. - f.wait_for("cursor on second row", |f| { - (f.state.lock_terminal().screen().cursor.y == 1).then_some(()) - }); - f.stop(); -} - -#[test] -fn terminal_query_reply_is_written_back_to_the_pty() { - // DA1 through the whole stack: child -> pty -> reader thread -> stream - // handler -> termio mailbox -> IO thread -> pty -> child. - let mut f = start( - sh("stty raw -echo; printf '\\033[c'; head -c 12 | cat -v; echo"), - |_| {}, - ); - f.wait_text("^[[?62;22;52c"); - f.stop(); -} - -#[test] -fn initial_input_is_written_after_start() { - let mut f = start(sh("cat"), |c| { - c.input.list.push(ReadableIO::Raw("hi there\\n".into())); - }); - f.wait_for("echo of input", |f| { - (f.text().matches("hi there").count() >= 2).then_some(()) - }); - f.stop(); -} - -#[test] -fn missing_input_file_shows_error_and_thread_keeps_draining() { - let mut f = start(sh("true"), |c| { - c.input.list.push(ReadableIO::Path( - ghostty_os::osbytes::EncodedOs::from_plain_text("/nonexistent/ghostty-input"), - )); - }); - f.wait_text("A configured `input` path was not found"); - assert!( - !f.state - .lock_terminal() - .modes - .get(ghostty_vt::modes::Mode::CursorVisible) - ); - // Messages are swallowed in drain mode and stop still works. - f.write(b"ignored"); - std::thread::sleep(Duration::from_millis(50)); - f.stop(); -} - -#[test] -fn resize_messages_are_coalesced_and_applied_to_the_pty() { - let mut f = start(sh("sleep 0.7; stty size"), |_| {}); - std::thread::sleep(Duration::from_millis(100)); - for i in 0..20u32 { - f.send(Message::Resize(size(60 + i % 5, 20 + i))); - } - f.send(Message::Resize(size(100, 40))); - f.wait_text("40 100"); - { - let t = f.state.lock_terminal(); - assert_eq!((t.cols, t.rows), (100, 40)); - assert_eq!(t.width_px, 1000); - assert_eq!(t.height_px, 800); - } - let n = f.renderer.resizes.lock().unwrap().len(); - assert!((1..21).contains(&n), "expected coalescing, got {n} resizes"); - assert_eq!( - *f.renderer.resizes.lock().unwrap().last().unwrap(), - size(100, 40) - ); - f.stop(); -} - -#[test] -fn synchronized_output_is_force_reset_by_timer() { - let mut f = start(sh("printf '\\033[?2026h'; sleep 5"), |_| {}); - f.wait_for("sync output on", |f| { - f.state - .lock_terminal() - .modes - .get(ghostty_vt::modes::Mode::SynchronizedOutput) - .then_some(()) - }); - let started = Instant::now(); - f.wait_for("sync output reset", |f| { - (!f.state - .lock_terminal() - .modes - .get(ghostty_vt::modes::Mode::SynchronizedOutput)) - .then_some(()) - }); - let waited = started.elapsed(); - assert!( - waited < Duration::from_millis(2500), - "reset took {waited:?}" - ); - f.stop(); -} - -#[test] -fn password_input_is_detected_via_termios_polling() { - let mut f = start(sh("stty -echo; sleep 3"), |_| {}); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let left = deadline.saturating_duration_since(Instant::now()); - match f.surface.recv_timeout(left) { - Ok(SurfaceMessage::PasswordInput(true)) => break, - Ok(_) => {} - Err(err) => panic!("never saw PasswordInput(true): {err}"), - } - } - f.stop(); -} - -#[test] -fn selection_scroll_timer_ticks_until_stopped() { - let mut f = start(sh("sleep 3"), |_| {}); - f.send(Message::SelectionScroll(true)); - let mut ticks = 0; - let deadline = Instant::now() + Duration::from_secs(5); - while ticks < 3 { - let left = deadline.saturating_duration_since(Instant::now()); - if let Ok(SurfaceMessage::SelectionScrollTick(true)) = f.surface.recv_timeout(left) { - ticks += 1; - } - assert!(Instant::now() < deadline); - } - f.send(Message::SelectionScroll(false)); - let mut saw_false = false; - let deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < deadline { - if let Ok(SurfaceMessage::SelectionScrollTick(false)) = - f.surface.recv_timeout(Duration::from_millis(100)) - { - saw_false = true; - break; - } - } - assert!(saw_false); - // And then it stops ticking. - std::thread::sleep(Duration::from_millis(100)); - while f.surface.try_recv().is_ok() {} - std::thread::sleep(Duration::from_millis(100)); - assert!(f.surface.try_recv().is_err()); - f.stop(); -} - -#[test] -fn focus_events_are_sent_when_enabled() { - let mut f = start( - sh("stty raw -echo; printf '\\033[?1004h'; head -c 3 | cat -v; echo"), - |_| {}, - ); - f.wait_for("focus mode", |f| { - f.state - .lock_terminal() - .modes - .get(ghostty_vt::modes::Mode::FocusEvent) - .then_some(()) - }); - f.send(Message::Focused(true)); - f.wait_text("^[[I"); - f.stop(); -} - -#[test] -fn size_report_mode_2048_and_xtwinops() { - let mut f = start( - sh("stty raw -echo; printf '\\033[18t'; head -c 10 | cat -v; echo"), - |_| {}, - ); - // CSI 18 t -> ESC [ 8 ; 30 ; 80 t - f.wait_text("^[[8;30;80t"); - f.stop(); -} - -#[test] -fn clear_screen_message_at_prompt_clears_and_sends_formfeed() { - let mut f = start( - sh("stty raw -echo; printf 'one\\ntwo\\n'; head -c 1 | cat -v; echo"), - |_| {}, - ); - f.wait_text("two"); - f.send(Message::ClearScreen { history: false }); - // Not at a prompt (no shell integration marks): rows above the cursor - // are erased and no form feed is sent. - f.wait_for("rows above cursor erased", |f| { - let t = f.text(); - (!t.contains("one")).then_some(()) - }); - f.stop(); -} - -#[test] -fn stop_kills_the_child_process_group() { - let dir = std::env::temp_dir().join(format!("ghostty-termio-pid-{}", std::process::id())); - let pidfile = dir.display().to_string(); - let mut f = start(sh(&format!("echo $$ > {pidfile}; exec sleep 60")), |_| {}); - let pid: i32 = f.wait_for("pid file", |_| { - std::fs::read_to_string(&dir) - .ok() - .and_then(|s| s.trim().parse().ok()) - }); - let started = Instant::now(); - f.stop(); - assert!(started.elapsed() < Duration::from_secs(5)); - let alive = rustix::process::test_kill_process(rustix::process::Pid::from_raw(pid).unwrap()); - assert!(alive.is_err(), "child {pid} is still alive"); - let _ = std::fs::remove_file(&dir); -} - -#[test] -fn writes_after_exit_are_ignored_and_thread_stays_alive_until_stopped() { - // `wait-after-command` keeps the surface (and so this thread) alive - // after the child exited. - let mut f = start(sh("true"), |_| {}); - let (code, _, _) = f.wait_exit(); - assert_eq!(code, 0); - f.write(b"data nobody reads"); - f.send(Message::Focused(true)); - f.send(Message::Resize(size(90, 25))); - f.wait_for("resize after exit", |f| { - (f.state.lock_terminal().cols == 90).then_some(()) - }); - f.stop(); -} - -#[test] -fn change_config_message_updates_palette_and_colors() { - let mut f = start(sh("sleep 3"), |_| {}); - let mut cfg = DerivedConfig::new(&base_config()); - cfg.background = ghostty_config::types_a::Color { r: 1, g: 2, b: 3 }; - cfg.enquiry_response = "x".into(); - f.send(Message::ChangeConfig(Box::new(cfg))); - f.wait_for("background change", |f| { - let t = f.state.lock_terminal(); - (t.colors.background.default == Some(ghostty_vt::color::Rgb { r: 1, g: 2, b: 3 })) - .then_some(()) - }); - assert!(f.state.lock_terminal().flags.dirty.palette); - f.stop(); -} - -#[test] -fn process_output_manual_api() { - // Termio can also be fed manually (no child involved). - let config = base_config(); - let mut ecfg = exec::Config::new( - ghostty_os::EnvMap::new(), - ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"), - ); - ecfg.command = Some(sh("true")); - let (tx, _rx) = unbounded(); - let termio = Termio::init(Options { - size: size(80, 30), - full_config: &config, - config: DerivedConfig::new(&config), - backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(), - mailbox: Mailbox::new().unwrap(), - renderer_wakeup: Wakeup::new().unwrap(), - renderer_mailbox: Arc::new(RendererRec::default()), - surface_mailbox: Arc::new(SurfaceRec(tx)), - }) - .unwrap(); - termio.process_output(b"manual\x1b[31m!"); - assert_eq!( - "manual!", - termio - .renderer_state() - .lock_terminal() - .plain_string() - .unwrap() - ); - termio.process_output(b"\x1b[5n"); - let m = termio.mailbox().pop().unwrap(); - assert!(matches!(m, Message::Write(w) if w.slice() == b"\x1b[0n")); -} +// `Kqueue` is not a candidate on Linux, so the reactor uses `poll(2)`. +suite!(poll, ghostty_os::xev::Backend::Kqueue); +#[cfg(target_os = "linux")] +suite!(epoll, ghostty_os::xev::Backend::Epoll); +#[cfg(target_os = "linux")] +suite!(io_uring, ghostty_os::xev::Backend::IoUring); diff --git a/crates/ghostty-termio/tests/exec_integration/suite.rs b/crates/ghostty-termio/tests/exec_integration/suite.rs new file mode 100644 index 0000000..e393409 --- /dev/null +++ b/crates/ghostty-termio/tests/exec_integration/suite.rs @@ -0,0 +1,680 @@ + +use std::{ + sync::{ + Arc, + Mutex, + atomic::{AtomicUsize, Ordering}, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +use crossbeam_channel::{Receiver, Sender, unbounded}; +use ghostty_apprt::surface::Message as SurfaceMessage; +use ghostty_config::{ + command::Command as ConfigCommand, + config::Config, + io::ReadableIO, + types_b::{LinuxCgroup, ShellIntegration}, +}; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_renderer::{ + message::Message as RendererMessage, + size::{CellSize, Padding, ScreenSize, Size}, +}; +use ghostty_termio::{ + Mailbox, + Options, + RendererMailbox, + SharedState, + SharedStateExt, + SurfaceMailbox, + Termio, + Thread, + Wakeup, + backend::{self, Backend}, + exec, + message::Message, + termio::DerivedConfig, +}; + +struct SurfaceRec(Sender); + +impl SurfaceMailbox for SurfaceRec { + fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize { + let _ = self.0.send(msg); + 1 + } +} + +#[derive(Default)] +struct RendererRec { + resizes: Mutex>, + reset_blinks: AtomicUsize, +} + +impl RendererMailbox for RendererRec { + fn push(&self, msg: RendererMessage, _t: Timeout) -> usize { + match msg { + RendererMessage::Resize(s) => self.resizes.lock().unwrap().push(s), + RendererMessage::ResetCursorBlink => { + self.reset_blinks.fetch_add(1, Ordering::SeqCst); + } + _ => {} + } + 1 + } +} + +fn size(cols: u32, rows: u32) -> Size { + Size { + screen: ScreenSize { + width: cols * 10, + height: rows * 20, + }, + cell: CellSize { + width: 10, + height: 20, + }, + padding: Padding::default(), + } +} + +struct Fixture { + state: SharedState, + mailbox: Mailbox, + stop: Wakeup, + thread: Option>, + surface: Receiver, + renderer: Arc, + renderer_wakeup: Wakeup, +} + +fn base_config() -> Config { + Config::default() +} + +fn start(command: ConfigCommand, tweak: impl FnOnce(&mut Config)) -> Fixture { + let mut config = base_config(); + tweak(&mut config); + + let mut ecfg = exec::Config::new( + ghostty_os::env::get_env_map(), + ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"), + ); + ecfg.command = Some(command); + ecfg.shell_integration = ShellIntegration::None; + // The default config has `linux-cgroup = single-instance` + + // `gtk-single-instance = detect` which only makes sense after the apprt + // resolved it. + ecfg.rt_pre_exec_info.linux_cgroup = LinuxCgroup::Never; + ecfg.rt_post_fork_info.linux_cgroup = LinuxCgroup::Never; + + let mailbox = Mailbox::new().unwrap(); + let (tx, surface) = unbounded(); + let renderer = Arc::new(RendererRec::default()); + let renderer_wakeup = Wakeup::new().unwrap(); + let termio = Termio::init(Options { + size: size(80, 30), + full_config: &config, + config: DerivedConfig::new(&config), + backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(), + mailbox: mailbox.clone(), + renderer_wakeup: renderer_wakeup.clone(), + renderer_mailbox: renderer.clone(), + surface_mailbox: Arc::new(SurfaceRec(tx)), + }) + .unwrap(); + let state = Arc::clone(termio.renderer_state()); + + let thread = Thread::with_backend(UNDER_TEST).unwrap(); + let stop = thread.stop_handle(); + let handle = std::thread::Builder::new() + .name("io".into()) + .spawn(move || thread.thread_main(termio)) + .unwrap(); + + Fixture { + state, + mailbox, + stop, + thread: Some(handle), + surface, + renderer, + renderer_wakeup, + } +} + +fn sh(script: &str) -> ConfigCommand { + ConfigCommand::Direct(vec!["/bin/sh".into(), "-c".into(), script.into()]) +} + +impl Fixture { + fn text(&self) -> String { + self.state.lock_terminal().plain_string().unwrap() + } + + fn send(&self, msg: Message) { + self.mailbox.send(msg); + self.mailbox.notify(); + } + + fn write(&self, data: &[u8]) { + self.send(Message::write_req(data)); + } + + /// Wait until `f` returns Some or time out (panics). + fn wait_for(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option) -> T { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(v) = f(self) { + return v; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {what}; terminal text = {:?}", + self.text() + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn wait_text(&self, needle: &str) { + self.wait_for(&format!("text {needle:?}"), |f| { + f.text().contains(needle).then_some(()) + }); + } + + /// Wait for the ChildExited surface message, returning all messages + /// received up to and including it. + fn wait_exit(&self) -> (u32, u64, Vec) { + let deadline = Instant::now() + Duration::from_secs(10); + let mut seen = Vec::new(); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + let msg = self + .surface + .recv_timeout(left) + .unwrap_or_else(|_| panic!("timed out waiting for child exit; seen={seen:?}")); + if let SurfaceMessage::ChildExited(c) = msg { + return (c.exit_code, c.runtime_ms, seen); + } + seen.push(msg); + } + } + + fn stop(&mut self) { + self.stop.notify().unwrap(); + if let Some(t) = self.thread.take() { + t.join().unwrap(); + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = self.stop.notify(); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +#[test] +fn printf_output_reaches_terminal_and_exit_is_reported() { + if !backend_available() { + return; + } + let mut f = start(sh("printf 'hello\\033[31mred'"), |_| {}); + let (code, runtime_ms, _) = f.wait_exit(); + assert_eq!(code, 0); + assert!(runtime_ms < 10_000); + + // The reader thread may still be draining the pty after the exit event. + f.wait_text("hellored"); + assert_eq!("hellored", f.text()); + + // The SGR was applied: the cursor pen is red (palette 1). + { + let t = f.state.lock_terminal(); + let style = t.screen().cursor.style; + assert!( + !matches!(style.fg_color, ghostty_terminal::style::Color::None), + "pen fg should be set after \\e[31m: {style:?}" + ); + assert_eq!(t.screen().cursor.x, 8); + } + // The renderer was woken up and told to reset the cursor blink. + assert!(f.renderer.reset_blinks.load(Ordering::SeqCst) >= 1); + assert!(f.renderer_wakeup_pending()); + f.stop(); +} + +impl Fixture { + fn renderer_wakeup_pending(&self) -> bool { + self.renderer_wakeup.drain() + } +} + +#[test] +fn nonzero_exit_code_is_reported() { + if !backend_available() { + return; + } + let mut f = start(sh("exit 3"), |_| {}); + let (code, _, _) = f.wait_exit(); + assert_eq!(code, 3); + f.stop(); +} + +#[test] +fn killed_by_signal_reports_signal_number() { + if !backend_available() { + return; + } + let mut f = start(sh("kill -KILL $$"), |_| {}); + let (code, _, _) = f.wait_exit(); + assert_eq!(code, 9); + f.stop(); +} + +#[test] +fn exec_failure_exits_quickly_with_error() { + if !backend_available() { + return; + } + // Abnormal exit detection itself lives in the Surface (it compares + // `runtime_ms` against `abnormal-command-exit-runtime`); termio must + // report a tiny runtime and the exit code. + let mut f = start( + ConfigCommand::Direct(vec!["/nonexistent/definitely-not-here".into()]), + |_| {}, + ); + let (code, runtime_ms, _) = f.wait_exit(); + assert_eq!(code, 1); + assert!(runtime_ms < 5_000, "runtime {runtime_ms}"); + f.stop(); +} + +#[test] +fn writes_reach_the_child_and_are_echoed() { + if !backend_available() { + return; + } + let mut f = start(sh("cat"), |_| {}); + f.write(b"abc\n"); + // Once by the tty echo and once by cat. + f.wait_for("two echoes", |f| { + (f.text().matches("abc").count() >= 2).then_some(()) + }); + f.stop(); +} + +#[test] +fn large_write_with_backpressure_is_delivered_in_order() { + if !backend_available() { + return; + } + // 256 KiB through a raw-mode `cat`: exercises the non-blocking write + // queue (the pty buffer is far smaller) and the output reader at the + // same time (cat would otherwise block on a full pty). + let mut f = start( + sh("stty raw -echo; echo ready; head -c 262144 | wc -c; echo done"), + |_| {}, + ); + // Writing before `stty raw` applies would go through the cooked-mode + // line discipline, which silently drops bytes past MAX_CANON (4095) + // on a line without a newline. + f.wait_text("ready"); + let chunk = vec![b'x'; 4096]; + for _ in 0..64 { + f.write(&chunk); + } + f.wait_text("262144"); + f.wait_text("done"); + f.stop(); +} + +#[test] +fn linefeed_mode_translates_cr() { + if !backend_available() { + return; + } + let mut f = start(sh("stty raw -echo; cat -v"), |_| {}); + // Give stty a moment so the first byte is not eaten by cooked mode. + std::thread::sleep(Duration::from_millis(300)); + f.send(Message::LinefeedMode(true)); + f.write(b"a\r"); + f.wait_text("a^M"); + // cat -v got "a\r\n", so it printed a newline after the ^M. + f.wait_for("cursor on second row", |f| { + (f.state.lock_terminal().screen().cursor.y == 1).then_some(()) + }); + f.stop(); +} + +#[test] +fn terminal_query_reply_is_written_back_to_the_pty() { + if !backend_available() { + return; + } + // DA1 through the whole stack: child -> pty -> reader thread -> stream + // handler -> termio mailbox -> IO thread -> pty -> child. + let mut f = start( + sh("stty raw -echo; printf '\\033[c'; head -c 12 | cat -v; echo"), + |_| {}, + ); + f.wait_text("^[[?62;22;52c"); + f.stop(); +} + +#[test] +fn initial_input_is_written_after_start() { + if !backend_available() { + return; + } + let mut f = start(sh("cat"), |c| { + c.input.list.push(ReadableIO::Raw("hi there\\n".into())); + }); + f.wait_for("echo of input", |f| { + (f.text().matches("hi there").count() >= 2).then_some(()) + }); + f.stop(); +} + +#[test] +fn missing_input_file_shows_error_and_thread_keeps_draining() { + if !backend_available() { + return; + } + let mut f = start(sh("true"), |c| { + c.input.list.push(ReadableIO::Path( + ghostty_os::osbytes::EncodedOs::from_plain_text("/nonexistent/ghostty-input"), + )); + }); + f.wait_text("A configured `input` path was not found"); + assert!( + !f.state + .lock_terminal() + .modes + .get(ghostty_vt::modes::Mode::CursorVisible) + ); + // Messages are swallowed in drain mode and stop still works. + f.write(b"ignored"); + std::thread::sleep(Duration::from_millis(50)); + f.stop(); +} + +#[test] +fn resize_messages_are_coalesced_and_applied_to_the_pty() { + if !backend_available() { + return; + } + let mut f = start(sh("sleep 0.7; stty size"), |_| {}); + std::thread::sleep(Duration::from_millis(100)); + for i in 0..20u32 { + f.send(Message::Resize(size(60 + i % 5, 20 + i))); + } + f.send(Message::Resize(size(100, 40))); + f.wait_text("40 100"); + { + let t = f.state.lock_terminal(); + assert_eq!((t.cols, t.rows), (100, 40)); + assert_eq!(t.width_px, 1000); + assert_eq!(t.height_px, 800); + } + let n = f.renderer.resizes.lock().unwrap().len(); + assert!((1..21).contains(&n), "expected coalescing, got {n} resizes"); + assert_eq!( + *f.renderer.resizes.lock().unwrap().last().unwrap(), + size(100, 40) + ); + f.stop(); +} + +#[test] +fn synchronized_output_is_force_reset_by_timer() { + if !backend_available() { + return; + } + let mut f = start(sh("printf '\\033[?2026h'; sleep 5"), |_| {}); + f.wait_for("sync output on", |f| { + f.state + .lock_terminal() + .modes + .get(ghostty_vt::modes::Mode::SynchronizedOutput) + .then_some(()) + }); + let started = Instant::now(); + f.wait_for("sync output reset", |f| { + (!f.state + .lock_terminal() + .modes + .get(ghostty_vt::modes::Mode::SynchronizedOutput)) + .then_some(()) + }); + let waited = started.elapsed(); + assert!( + waited < Duration::from_millis(2500), + "reset took {waited:?}" + ); + f.stop(); +} + +#[test] +fn password_input_is_detected_via_termios_polling() { + if !backend_available() { + return; + } + let mut f = start(sh("stty -echo; sleep 3"), |_| {}); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + match f.surface.recv_timeout(left) { + Ok(SurfaceMessage::PasswordInput(true)) => break, + Ok(_) => {} + Err(err) => panic!("never saw PasswordInput(true): {err}"), + } + } + f.stop(); +} + +#[test] +fn selection_scroll_timer_ticks_until_stopped() { + if !backend_available() { + return; + } + let mut f = start(sh("sleep 3"), |_| {}); + f.send(Message::SelectionScroll(true)); + let mut ticks = 0; + let deadline = Instant::now() + Duration::from_secs(5); + while ticks < 3 { + let left = deadline.saturating_duration_since(Instant::now()); + if let Ok(SurfaceMessage::SelectionScrollTick(true)) = f.surface.recv_timeout(left) { + ticks += 1; + } + assert!(Instant::now() < deadline); + } + f.send(Message::SelectionScroll(false)); + let mut saw_false = false; + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if let Ok(SurfaceMessage::SelectionScrollTick(false)) = + f.surface.recv_timeout(Duration::from_millis(100)) + { + saw_false = true; + break; + } + } + assert!(saw_false); + // And then it stops ticking. + std::thread::sleep(Duration::from_millis(100)); + while f.surface.try_recv().is_ok() {} + std::thread::sleep(Duration::from_millis(100)); + assert!(f.surface.try_recv().is_err()); + f.stop(); +} + +#[test] +fn focus_events_are_sent_when_enabled() { + if !backend_available() { + return; + } + let mut f = start( + sh("stty raw -echo; printf '\\033[?1004h'; head -c 3 | cat -v; echo"), + |_| {}, + ); + f.wait_for("focus mode", |f| { + f.state + .lock_terminal() + .modes + .get(ghostty_vt::modes::Mode::FocusEvent) + .then_some(()) + }); + f.send(Message::Focused(true)); + f.wait_text("^[[I"); + f.stop(); +} + +#[test] +fn size_report_mode_2048_and_xtwinops() { + if !backend_available() { + return; + } + let mut f = start( + sh("stty raw -echo; printf '\\033[18t'; head -c 10 | cat -v; echo"), + |_| {}, + ); + // CSI 18 t -> ESC [ 8 ; 30 ; 80 t + f.wait_text("^[[8;30;80t"); + f.stop(); +} + +#[test] +fn clear_screen_message_at_prompt_clears_and_sends_formfeed() { + if !backend_available() { + return; + } + let mut f = start( + sh("stty raw -echo; printf 'one\\ntwo\\n'; head -c 1 | cat -v; echo"), + |_| {}, + ); + f.wait_text("two"); + f.send(Message::ClearScreen { history: false }); + // Not at a prompt (no shell integration marks): rows above the cursor + // are erased and no form feed is sent. + f.wait_for("rows above cursor erased", |f| { + let t = f.text(); + (!t.contains("one")).then_some(()) + }); + f.stop(); +} + +#[test] +fn stop_kills_the_child_process_group() { + if !backend_available() { + return; + } + let dir = std::env::temp_dir().join(format!("ghostty-termio-pid-{}-{UNDER_TEST}", std::process::id())); + let pidfile = dir.display().to_string(); + let mut f = start(sh(&format!("echo $$ > {pidfile}; exec sleep 60")), |_| {}); + let pid: i32 = f.wait_for("pid file", |_| { + std::fs::read_to_string(&dir) + .ok() + .and_then(|s| s.trim().parse().ok()) + }); + let started = Instant::now(); + f.stop(); + assert!(started.elapsed() < Duration::from_secs(5)); + let alive = rustix::process::test_kill_process(rustix::process::Pid::from_raw(pid).unwrap()); + assert!(alive.is_err(), "child {pid} is still alive"); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn writes_after_exit_are_ignored_and_thread_stays_alive_until_stopped() { + if !backend_available() { + return; + } + // `wait-after-command` keeps the surface (and so this thread) alive + // after the child exited. + let mut f = start(sh("true"), |_| {}); + let (code, _, _) = f.wait_exit(); + assert_eq!(code, 0); + f.write(b"data nobody reads"); + f.send(Message::Focused(true)); + f.send(Message::Resize(size(90, 25))); + f.wait_for("resize after exit", |f| { + (f.state.lock_terminal().cols == 90).then_some(()) + }); + f.stop(); +} + +#[test] +fn change_config_message_updates_palette_and_colors() { + if !backend_available() { + return; + } + let mut f = start(sh("sleep 3"), |_| {}); + let mut cfg = DerivedConfig::new(&base_config()); + cfg.background = ghostty_config::types_a::Color { r: 1, g: 2, b: 3 }; + cfg.enquiry_response = "x".into(); + f.send(Message::ChangeConfig(Box::new(cfg))); + f.wait_for("background change", |f| { + let t = f.state.lock_terminal(); + (t.colors.background.default == Some(ghostty_vt::color::Rgb { r: 1, g: 2, b: 3 })) + .then_some(()) + }); + assert!(f.state.lock_terminal().flags.dirty.palette); + f.stop(); +} + +#[test] +fn process_output_manual_api() { + if !backend_available() { + return; + } + // Termio can also be fed manually (no child involved). + let config = base_config(); + let mut ecfg = exec::Config::new( + ghostty_os::EnvMap::new(), + ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"), + ); + ecfg.command = Some(sh("true")); + let (tx, _rx) = unbounded(); + let termio = Termio::init(Options { + size: size(80, 30), + full_config: &config, + config: DerivedConfig::new(&config), + backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(), + mailbox: Mailbox::new().unwrap(), + renderer_wakeup: Wakeup::new().unwrap(), + renderer_mailbox: Arc::new(RendererRec::default()), + surface_mailbox: Arc::new(SurfaceRec(tx)), + }) + .unwrap(); + termio.process_output(b"manual\x1b[31m!"); + assert_eq!( + "manual!", + termio + .renderer_state() + .lock_terminal() + .plain_string() + .unwrap() + ); + termio.process_output(b"\x1b[5n"); + let m = termio.mailbox().pop().unwrap(); + assert!(matches!(m, Message::Write(w) if w.slice() == b"\x1b[0n")); +} + +/// Skip (and say so) when the event loop backend is unavailable here, e.g. +/// io_uring denied by the kernel or sandbox. +fn backend_available() -> bool { + // (`Kqueue` stands for the portable poll(2) loop here.) + let ok = UNDER_TEST != ghostty_os::xev::Backend::IoUring + || ghostty_os::xev::available(UNDER_TEST); + if !ok { + eprintln!("SKIP: event loop backend {UNDER_TEST} is unavailable on this system"); + } + ok +} diff --git a/docs/rewrite/notes/final-Z16.md b/docs/rewrite/notes/final-Z16.md new file mode 100644 index 0000000..61de75a --- /dev/null +++ b/docs/rewrite/notes/final-Z16.md @@ -0,0 +1,42 @@ +# Z16: RV13 P3 fixes + +## 1. auto-update-channel +`Config::finalize` now defaults the field from `ghostty_build_info::RELEASE_CHANNEL` (Zig: +`build_config.release_channel`); ghostty-config gained the dep. Tests: default follows the build +channel, explicit value kept. `+show-config` for this tip build prints `tip` like /usr/bin/ghostty. + +## 2. async-backend +* `ghostty_os::xev` (new): libxev `Dynamic` selection: `Backend`, `candidates` (Linux: io_uring, epoll; + others single), `backend()` (process global, default = last candidate = epoll), `available` + (io_uring: `IoUring::new(256)` like libxev, plus we require EXT_ARG and POLL_ADD/POLL_REMOVE probes), + `detect()`, `prefer()`. +* `ghostty-core` global init: detect (warn on failure) and `libxev default backend=..` info log (global.zig). + `+version` prints the real backend instead of `n/a`. +* GTK `Application::startup` calls `startup_xev` -> `apply_async_backend` (application.zig startupXev): + `auto` no-op, else `prefer`, logs `libxev manual backend=` / `libxev manual backend failed, using default=`. +* `ghostty_termio::reactor` (new): `Reactor` = `Epoll` (persistent epoll, registrations diffed per wait), + `IoUring` (`io-uring` crate: one-shot POLL_ADD per interest, POLL_REMOVE on drop, EXT_ARG timeout; + unsafe only for SQ push with SAFETY comment) or `Poll` (poll(2), used where libxev has one fixed backend, + i.e. macOS kqueue). `Thread::new()` uses `xev::backend()`; `Thread::with_backend` for tests. The IO + loop and the post-failure drain loop run on it. +* Not changed: the renderer and search threads use crossbeam channels (not fd loops), and the pty reader + is a dedicated blocking thread (as in Zig), so there is nothing to select there. +* Tests: reactor unit tests (timeout, level trigger, drop/re-add, cross-thread wake, fd reuse) per backend; + `tests/exec_integration` now runs the whole 21-test suite 3x (poll, epoll, io_uring; io_uring skips with + a SKIP message if unavailable). In this sandbox io_uring IS available and was exercised. + (Fixed a test-only pid-file collision between suites.) + +## 3. Windows zig_error +Installed Zig std is 0.16 (not 0.15.2) so the table follows 0.15.2 `windows.OpenFile` from knowledge of +its NTSTATUS arms, translated to Win32 codes (see the doc table in `os/zig_error.rs`): sharing violation, +access denied, user-mapped-file -> AccessDenied; invalid name -> BadPathName; BAD_NETPATH/BAD_NET_NAME -> +NetworkNotFound; NOT_READY -> NoDevice; PIPE_BUSY -> PipeBusy; ALREADY_EXISTS -> PathAlreadyExists; +ERROR_DIRECTORY -> NotDir; 206 -> NameTooLong. Removed CreateProcess-only entries (FileBusy, InvalidName, +InvalidExe, SystemResources, NoSpaceLeft) which file opens never produce (Unexpected). Table is a +host-independent fn so it is tested on Linux. Caveat: 0.16 retries sharing violations then FileBusy; the +oracle is 0.15.2 behaviour (AccessDenied), as requested. + +## 4. basename +`config/theme.rs`: exact `ComponentIterator` + `parsePathWindows` port (`basename_windows`, `basename_posix`): +any 1-3 byte code point + `:` is a drive root (so `1:theme` -> `theme`), UNC/local-device roots. Tests +include all `testBasenameWindows` cases from Zig std.