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)
This commit is contained in:
ddidderr committed 2026-10-12 14:49:00 +02:00
1 parent 3ce3225642
commit 1bd918159a
18 files changed
+2008 -738

No files matched your search

Generated
+14
View File
@@ -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"
@@ -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::<gio::Application>());
// 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();
}
}
+1 -2
View File
@@ -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!(
+1
View File
@@ -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
+38 -1
View File
@@ -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() {
+157 -15
View File
@@ -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(""), "");
+13
View File
@@ -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();
+4
View File
@@ -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"
+1
View File
@@ -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 {
+214
View File
@@ -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();
}
}
+80 -63
View File
@@ -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");
}
}
+4
View File
@@ -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"] }
+2
View File
@@ -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;
+553
View File
@@ -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<UringReactor>),
}
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<Reactor> {
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<Duration>,
ready: &mut Vec<usize>,
) -> 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<Duration>,
ready: &mut Vec<usize>,
) -> io::Result<()> {
let ts = timeout.map(timespec);
let mut fds: Vec<PollFd<'_>> = 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<usize, (RawFd, bool)>,
}
#[cfg(target_os = "linux")]
impl EpollReactor {
fn new() -> io::Result<EpollReactor> {
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<usize> = 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<Duration>,
ready: &mut Vec<usize>,
) -> io::Result<()> {
use rustix::event::epoll;
self.sync(interests)?;
// (`Event` is not `Send`, so it cannot live in the reactor.)
let mut events: Vec<epoll::Event> = 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<usize, (RawFd, bool, u64)>,
next_id: u64,
}
#[cfg(target_os = "linux")]
impl UringReactor {
fn new() -> io::Result<UringReactor> {
// 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<usize> = 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<usize>) {
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<Duration>,
ready: &mut Vec<usize>,
) -> 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<Backend> {
#[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());
}
}
}
+117 -56
View File
@@ -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<Reactor>,
/// The surface dimensions recorded in crash reports.
pub crash_dims: Option<std::sync::Arc<ghostty_crash::SurfaceDims>>,
}
@@ -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<Thread> {
#[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<Thread> {
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<Thread> {
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();
loop {
let mut fds = [
PollFd::from_borrowed_fd(mailbox.wakeup().as_fd(), PollFlags::IN),
PollFd::from_borrowed_fd(self.stop.as_fd(), PollFlags::IN),
];
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}");
let Some(mut reactor) = self.reactor.take() else {
return;
};
let mut ready = Vec::new();
loop {
let interests = [
Interest {
token: TOKEN_MAILBOX,
fd: mailbox.wakeup().as_fd(),
write: false,
},
Interest {
token: TOKEN_STOP,
fd: self.stop.as_fd(),
write: false,
},
];
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<Instant> {
@@ -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<usize> = 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<PollFd<'_>> = 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<Interest<'_>> = 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
if let Some(fd) = exec.process_fd() {
interests.push(Interest {
token: TOKEN_PROCESS,
fd,
write: false,
});
let fp_idx = exec.flatpak_fd().map(|fd| {
fds.push(PollFd::from_borrowed_fd(fd, PollFlags::IN));
fds.len() - 1
}
if let Some(fd) = exec.flatpak_fd() {
interests.push(Interest {
token: TOKEN_FLATPAK,
fd,
write: false,
});
let out_idx = exec.wants_write().then(|| {
fds.push(PollFd::from_borrowed_fd(exec.pty_fd(), PollFlags::OUT));
fds.len() - 1
}
if exec.wants_write() {
interests.push(Interest {
token: TOKEN_PTY_OUT,
fd: exec.pty_fd(),
write: true,
});
}
match rustix::event::poll(&mut fds, timeout.as_ref()) {
Ok(_) => {}
Err(rustix::io::Errno::INTR) => continue,
Err(err) => {
if let Err(err) = reactor.wait(&interests, timeout, &mut ready) {
log::error!("io thread poll failed err={err}");
return;
}
}
let rev: Vec<PollFlags> = fds.iter().map(PollFd::revents).collect();
drop(fds);
let ready = |i: usize| !rev[i].is_empty();
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();
}
+17 -601
View File
@@ -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<SurfaceMessage>);
impl SurfaceMailbox for SurfaceRec {
fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize {
let _ = self.0.send(msg);
1
macro_rules! suite {
($name:ident, $be:expr) => {
mod $name {
const UNDER_TEST: ghostty_os::xev::Backend = $be;
include!("exec_integration/suite.rs");
}
};
}
#[derive(Default)]
struct RendererRec {
resizes: Mutex<Vec<Size>>,
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<JoinHandle<()>>,
surface: Receiver<SurfaceMessage>,
renderer: Arc<RendererRec>,
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<T>(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option<T>) -> 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<SurfaceMessage>) {
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);
@@ -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<SurfaceMessage>);
impl SurfaceMailbox for SurfaceRec {
fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize {
let _ = self.0.send(msg);
1
}
}
#[derive(Default)]
struct RendererRec {
resizes: Mutex<Vec<Size>>,
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<JoinHandle<()>>,
surface: Receiver<SurfaceMessage>,
renderer: Arc<RendererRec>,
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<T>(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option<T>) -> 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<SurfaceMessage>) {
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
}
+42
View File
@@ -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.