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 27e241d07b
commit f9094c8152
18 files changed
+2012 -742

No files matched your search

+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");
}
}