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:
1 parent
3ce3225642
commit
1bd918159a
18 files changed
+2008
-738
No files matched your search
Generated
+14
@@ -1449,6 +1449,7 @@ name = "ghostty-config"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const_format",
|
"const_format",
|
||||||
|
"ghostty-build-info",
|
||||||
"ghostty-input",
|
"ghostty-input",
|
||||||
"ghostty-os",
|
"ghostty-os",
|
||||||
"ghostty-terminal",
|
"ghostty-terminal",
|
||||||
@@ -1621,6 +1622,7 @@ dependencies = [
|
|||||||
"gettext-rs",
|
"gettext-rs",
|
||||||
"ghostty-vt",
|
"ghostty-vt",
|
||||||
"ghostty-zigfmt",
|
"ghostty-zigfmt",
|
||||||
|
"io-uring",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
@@ -1723,6 +1725,7 @@ dependencies = [
|
|||||||
"ghostty-renderer",
|
"ghostty-renderer",
|
||||||
"ghostty-terminal",
|
"ghostty-terminal",
|
||||||
"ghostty-vt",
|
"ghostty-vt",
|
||||||
|
"io-uring",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
@@ -2150,6 +2153,17 @@ dependencies = [
|
|||||||
"syn 3.0.8",
|
"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]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
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
|
/// 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
|
/// back to `en_US.UTF-8` (updating `LANG` on success). Idempotent. This must
|
||||||
/// run before GTK initializes and before the child environment is captured.
|
/// 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() {
|
pub fn init_locale() {
|
||||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
ONCE.call_once(|| {
|
ONCE.call_once(|| {
|
||||||
@@ -329,6 +356,9 @@ mod imp {
|
|||||||
// Set ourselves as the default application.
|
// Set ourselves as the default application.
|
||||||
gio::Application::set_default(obj.upcast_ref::<gio::Application>());
|
gio::Application::set_default(obj.upcast_ref::<gio::Application>());
|
||||||
|
|
||||||
|
// Setup our event loop
|
||||||
|
obj.startup_xev();
|
||||||
|
|
||||||
// Setup our style manager (light/dark mode)
|
// Setup our style manager (light/dark mode)
|
||||||
obj.startup_style_manager();
|
obj.startup_style_manager();
|
||||||
// Setup some signal handlers
|
// Setup some signal handlers
|
||||||
@@ -889,6 +919,14 @@ impl Application {
|
|||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Startup
|
// 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
|
/// Setup the style manager on startup: initial light/dark mode based on
|
||||||
/// the configuration, plus change listeners.
|
/// the configuration, plus change listeners.
|
||||||
fn startup_style_manager(&self) {
|
fn startup_style_manager(&self) {
|
||||||
@@ -1900,3 +1938,35 @@ mod trim_tests {
|
|||||||
assert_eq!(trim_ascii_whitespace(" \t "), "");
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, " - app runtime : {}", info.app_runtime)?;
|
||||||
writeln!(out, " - font engine : {}", info.font_backend)?;
|
writeln!(out, " - font engine : {}", info.font_backend)?;
|
||||||
writeln!(out, " - renderer : {}", info.renderer)?;
|
writeln!(out, " - renderer : {}", info.renderer)?;
|
||||||
// There is no libxev in the Rust port.
|
writeln!(out, " - libxev : {}", ghostty_os::xev::backend())?;
|
||||||
writeln!(out, " - libxev : n/a")?;
|
|
||||||
if info.app_runtime == ".gtk" {
|
if info.app_runtime == ".gtk" {
|
||||||
let kernel_info = ghostty_os::get_kernel_info();
|
let kernel_info = ghostty_os::get_kernel_info();
|
||||||
writeln!(
|
writeln!(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
const_format = "0.2.36"
|
const_format = "0.2.36"
|
||||||
|
ghostty-build-info.workspace = true
|
||||||
ghostty-input.workspace = true
|
ghostty-input.workspace = true
|
||||||
ghostty-os.workspace = true
|
ghostty-os.workspace = true
|
||||||
ghostty-terminal.workspace = true
|
ghostty-terminal.workspace = true
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ use crate::{
|
|||||||
const NS_PER_S: u64 = 1_000_000_000;
|
const NS_PER_S: u64 = 1_000_000_000;
|
||||||
const NS_PER_MS: u64 = 1_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! {
|
config_enum! {
|
||||||
/// The release channel of the build (Zig: `build_config.ReleaseChannel`).
|
/// The release channel of the build (Zig: `build_config.ReleaseChannel`).
|
||||||
pub enum ReleaseChannel {
|
pub enum ReleaseChannel {
|
||||||
@@ -4656,7 +4664,7 @@ impl Config {
|
|||||||
// We can't set this as a struct default because our config is
|
// We can't set this as a struct default because our config is
|
||||||
// loaded in environments where a build config isn't available.
|
// loaded in environments where a build config isn't available.
|
||||||
if self.auto_update_channel.is_none() {
|
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);
|
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
|
clippy::assert_is_empty
|
||||||
)]
|
)]
|
||||||
mod tests {
|
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)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn shell_from_env_round_trips_bytes() {
|
fn shell_from_env_round_trips_bytes() {
|
||||||
|
|||||||
@@ -151,24 +151,43 @@ fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `std.fs.path.basename` (last `ComponentIterator` component; `.` and `..`
|
/// `std.fs.path.basename` for the host: `basenamePosix` or `basenameWindows`.
|
||||||
/// 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.
|
|
||||||
fn zig_basename(path: &str) -> &str {
|
fn zig_basename(path: &str) -> &str {
|
||||||
let is_sep = |b: u8| b == b'/' || (cfg!(windows) && b == b'\\');
|
if cfg!(windows) {
|
||||||
let bytes = path.as_bytes();
|
basename_windows(path)
|
||||||
let root_end = if cfg!(windows)
|
|
||||||
&& bytes.len() >= 2
|
|
||||||
&& bytes[0].is_ascii_alphabetic()
|
|
||||||
&& bytes[1] == b':'
|
|
||||||
{
|
|
||||||
2
|
|
||||||
} else {
|
} 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();
|
let mut end = bytes.len();
|
||||||
loop {
|
loop {
|
||||||
if end == root_end {
|
if end == root_end_index {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if !is_sep(bytes[end - 1]) {
|
if !is_sep(bytes[end - 1]) {
|
||||||
@@ -177,12 +196,73 @@ fn zig_basename(path: &str) -> &str {
|
|||||||
end -= 1;
|
end -= 1;
|
||||||
}
|
}
|
||||||
let mut start = end;
|
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;
|
start -= 1;
|
||||||
}
|
}
|
||||||
&path[start..end]
|
&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
|
/// 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
|
/// 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.
|
/// 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]
|
#[test]
|
||||||
fn zig_basename_matches_std() {
|
fn zig_basename_matches_std() {
|
||||||
assert_eq!(zig_basename(""), "");
|
assert_eq!(zig_basename(""), "");
|
||||||
|
|||||||
@@ -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
|
// SIGPIPE is ignored by the Rust runtime already (it sets SIG_IGN
|
||||||
// before main), which is what Zig's `initSignals` does.
|
// 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.
|
// As early as possible, initialize our resource limits.
|
||||||
let rlimits = ResourceLimits::init();
|
let rlimits = ResourceLimits::init();
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ windows-sys = { version = "0.61", features = [
|
|||||||
"Win32_System_Threading",
|
"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]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2.190"
|
libc = "0.2.190"
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub mod file_type;
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod flatpak;
|
pub mod flatpak;
|
||||||
pub mod osbytes;
|
pub mod osbytes;
|
||||||
|
pub mod xev;
|
||||||
pub use ghostty_zigfmt as zig_fmt;
|
pub use ghostty_zigfmt as zig_fmt;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod flatpak {
|
pub mod flatpak {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,63 +61,67 @@ pub fn errno_name(errno: i32) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// See the unix variant; this is the Windows (Win32 error code) table.
|
/// 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]
|
#[must_use]
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[allow(non_upper_case_globals)] // windows-sys spells two constants `..._64k`
|
|
||||||
pub fn errno_name(code: i32) -> &'static str {
|
pub fn errno_name(code: i32) -> &'static str {
|
||||||
use windows_sys::Win32::Foundation::{
|
windows_errno_name(code as u32)
|
||||||
ERROR_ACCESS_DENIED,
|
}
|
||||||
ERROR_AUTODATASEG_EXCEEDS_64k,
|
|
||||||
ERROR_BAD_EXE_FORMAT,
|
/// The Windows table on plain numbers (so it is testable on every host).
|
||||||
ERROR_BAD_FORMAT,
|
#[must_use]
|
||||||
ERROR_COMMITMENT_LIMIT,
|
#[cfg_attr(not(windows), allow(dead_code))]
|
||||||
ERROR_DIRECTORY,
|
fn windows_errno_name(code: u32) -> &'static str {
|
||||||
ERROR_DISK_FULL,
|
// Win32 error codes (winerror.h). Mirrors the NTSTATUS mapping of
|
||||||
ERROR_DYNLINK_FROM_INVALID_RING,
|
// `std.os.windows.OpenFile` in Zig 0.15.2 (verified against its source).
|
||||||
ERROR_EXE_MACHINE_TYPE_MISMATCH,
|
const ERROR_FILE_NOT_FOUND: u32 = 2;
|
||||||
ERROR_EXE_MARKED_INVALID,
|
const ERROR_PATH_NOT_FOUND: u32 = 3;
|
||||||
ERROR_FILE_NOT_FOUND,
|
const ERROR_ACCESS_DENIED: u32 = 5;
|
||||||
ERROR_FILENAME_EXCED_RANGE,
|
const ERROR_NOT_READY: u32 = 21;
|
||||||
ERROR_INFLOOP_IN_RELOC_CHAIN,
|
const ERROR_SHARING_VIOLATION: u32 = 32;
|
||||||
ERROR_INVALID_EXE_SIGNATURE,
|
const ERROR_BAD_NETPATH: u32 = 53;
|
||||||
ERROR_INVALID_MINALLOCSIZE,
|
const ERROR_BAD_NET_NAME: u32 = 67;
|
||||||
ERROR_INVALID_MODULETYPE,
|
const ERROR_FILE_EXISTS: u32 = 80;
|
||||||
ERROR_INVALID_NAME,
|
const ERROR_INVALID_NAME: u32 = 123;
|
||||||
ERROR_INVALID_SEGDPL,
|
const ERROR_ALREADY_EXISTS: u32 = 183;
|
||||||
ERROR_INVALID_STACKSEG,
|
const ERROR_FILENAME_EXCED_RANGE: u32 = 206;
|
||||||
ERROR_INVALID_STARTING_CODESEG,
|
const ERROR_VIRUS_INFECTED: u32 = 225;
|
||||||
ERROR_IOPL_NOT_ENABLED,
|
const ERROR_VIRUS_DELETED: u32 = 226;
|
||||||
ERROR_ITERATED_DATA_EXCEEDS_64k,
|
const ERROR_PIPE_BUSY: u32 = 231;
|
||||||
ERROR_PATH_NOT_FOUND,
|
const ERROR_DIRECTORY: u32 = 267;
|
||||||
ERROR_RELOC_CHAIN_XEEDS_SEGLIM,
|
const ERROR_USER_MAPPED_FILE: u32 = 1224;
|
||||||
ERROR_RING2SEG_MUST_BE_MOVABLE,
|
match code {
|
||||||
ERROR_SHARING_VIOLATION,
|
ERROR_INVALID_NAME => "BadPathName",
|
||||||
};
|
ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => "FileNotFound",
|
||||||
match code as u32 {
|
ERROR_BAD_NETPATH | ERROR_BAD_NET_NAME => "NetworkNotFound",
|
||||||
ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND | ERROR_DIRECTORY => "FileNotFound",
|
ERROR_NOT_READY => "NoDevice",
|
||||||
ERROR_ACCESS_DENIED => "AccessDenied",
|
// A sharing violation is `AccessDenied` in `OpenFile` (the
|
||||||
ERROR_INVALID_NAME => "InvalidName",
|
// 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_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",
|
_ => "Unexpected",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,25 +213,38 @@ mod tests {
|
|||||||
assert_eq!(errno_name(40), "Unexpected");
|
assert_eq!(errno_name(40), "Unexpected");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_table() {
|
fn windows_table() {
|
||||||
for (code, name) in [
|
for (code, name) in [
|
||||||
(2, "FileNotFound"),
|
(2u32, "FileNotFound"),
|
||||||
(3, "FileNotFound"),
|
(3, "FileNotFound"),
|
||||||
(5, "AccessDenied"),
|
(5, "AccessDenied"),
|
||||||
(32, "FileBusy"),
|
// ERROR_SHARING_VIOLATION: AccessDenied like OpenFile.
|
||||||
(112, "NoSpaceLeft"),
|
(32, "AccessDenied"),
|
||||||
(123, "InvalidName"),
|
(1224, "AccessDenied"),
|
||||||
(193, "InvalidExe"),
|
(21, "NoDevice"),
|
||||||
|
(53, "NetworkNotFound"),
|
||||||
|
(67, "NetworkNotFound"),
|
||||||
|
(123, "BadPathName"),
|
||||||
|
(183, "PathAlreadyExists"),
|
||||||
|
(80, "PathAlreadyExists"),
|
||||||
|
(225, "AntivirusInterference"),
|
||||||
|
(226, "AntivirusInterference"),
|
||||||
(206, "NameTooLong"),
|
(206, "NameTooLong"),
|
||||||
(267, "FileNotFound"),
|
(231, "PipeBusy"),
|
||||||
(1455, "SystemResources"),
|
(267, "NotDir"),
|
||||||
(87, "Unexpected"),
|
(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");
|
assert_eq!(io_error_name(&e), "error.AccessDenied");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,10 @@ ghostty-vt.workspace = true
|
|||||||
log = "0.4.34"
|
log = "0.4.34"
|
||||||
thiserror = "2.0.21"
|
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]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2.190"
|
libc = "0.2.190"
|
||||||
nix = { version = "0.31.3", features = ["signal"] }
|
nix = { version = "0.31.3", features = ["signal"] }
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ pub mod message;
|
|||||||
pub mod options;
|
pub mod options;
|
||||||
#[cfg_attr(windows, path = "pty_windows.rs")]
|
#[cfg_attr(windows, path = "pty_windows.rs")]
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub mod reactor;
|
||||||
pub mod shell_integration;
|
pub mod shell_integration;
|
||||||
pub mod sinks;
|
pub mod sinks;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
//!
|
//!
|
||||||
//! # Event loop
|
//! # 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
|
//! 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
|
//! wakeup), the pty (`POLLOUT`, only while writes are queued) and a set of
|
||||||
//! deadlines (selection scroll, resize coalescing, synchronized-output reset,
|
//! 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_datastruct::blocking_queue::Timeout;
|
||||||
use ghostty_renderer::size::Size;
|
use ghostty_renderer::size::Size;
|
||||||
use ghostty_vt::{csi, modes::Mode};
|
use ghostty_vt::{csi, modes::Mode};
|
||||||
#[cfg(unix)]
|
|
||||||
use rustix::event::{PollFd, PollFlags};
|
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use crate::reactor::{Backend, Interest, Reactor};
|
||||||
use crate::{
|
use crate::{
|
||||||
backend::ThreadData as BackendThreadData,
|
backend::ThreadData as BackendThreadData,
|
||||||
message::Message,
|
message::Message,
|
||||||
@@ -43,6 +44,17 @@ use crate::{
|
|||||||
#[path = "thread_windows.rs"]
|
#[path = "thread_windows.rs"]
|
||||||
mod windows_loop;
|
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.
|
/// The number of milliseconds to coalesce certain messages like resize for.
|
||||||
/// Not all message types are coalesced.
|
/// Not all message types are coalesced.
|
||||||
pub const COALESCE_MIN_MS: u64 = 25;
|
pub const COALESCE_MIN_MS: u64 = 25;
|
||||||
@@ -87,6 +99,10 @@ pub struct Thread {
|
|||||||
|
|
||||||
flags: Flags,
|
flags: Flags,
|
||||||
|
|
||||||
|
/// The event demultiplexer (the xev loop). Taken out while running.
|
||||||
|
#[cfg(unix)]
|
||||||
|
reactor: Option<Reactor>,
|
||||||
|
|
||||||
/// The surface dimensions recorded in crash reports.
|
/// The surface dimensions recorded in crash reports.
|
||||||
pub crash_dims: Option<std::sync::Arc<ghostty_crash::SurfaceDims>>,
|
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
|
/// 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.
|
/// is up to the caller to run [`Thread::thread_main`] on a thread.
|
||||||
pub fn new() -> io::Result<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 {
|
Ok(Thread {
|
||||||
|
#[cfg(unix)]
|
||||||
|
reactor: None,
|
||||||
stop: Wakeup::new()?,
|
stop: Wakeup::new()?,
|
||||||
scroll_active: false,
|
scroll_active: false,
|
||||||
scroll_deadline: None,
|
scroll_deadline: None,
|
||||||
@@ -156,30 +196,37 @@ impl Thread {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn drain_until_stop(&mut self, io: &Termio) {
|
fn drain_until_stop(&mut self, io: &Termio) {
|
||||||
let mailbox = io.mailbox().clone();
|
let mailbox = io.mailbox().clone();
|
||||||
loop {
|
let Some(mut reactor) = self.reactor.take() else {
|
||||||
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}");
|
|
||||||
return;
|
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;
|
||||||
}
|
}
|
||||||
}
|
if ready.contains(&TOKEN_MAILBOX) {
|
||||||
let stop = fds[1].revents().contains(PollFlags::IN);
|
|
||||||
let msgs = fds[0].revents().contains(PollFlags::IN);
|
|
||||||
if msgs {
|
|
||||||
mailbox.wakeup().drain();
|
mailbox.wakeup().drain();
|
||||||
while mailbox.pop().is_some() {}
|
while mailbox.pop().is_some() {}
|
||||||
}
|
}
|
||||||
if stop {
|
if ready.contains(&TOKEN_STOP) {
|
||||||
self.stop.drain();
|
self.stop.drain();
|
||||||
return;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.reactor = Some(reactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_deadline(&self, td: &ThreadData) -> Option<Instant> {
|
fn next_deadline(&self, td: &ThreadData) -> Option<Instant> {
|
||||||
@@ -199,72 +246,86 @@ impl Thread {
|
|||||||
/// The event loop. Returns when stopped.
|
/// The event loop. Returns when stopped.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn run(&mut self, io: &mut Termio, td: &mut ThreadData) {
|
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 mailbox = io.mailbox().clone();
|
||||||
|
let mut ready: Vec<usize> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let timeout = self.next_deadline(td).map(|d| {
|
let timeout = self
|
||||||
let dur = d.saturating_duration_since(Instant::now());
|
.next_deadline(td)
|
||||||
rustix::time::Timespec {
|
.map(|d| d.saturating_duration_since(Instant::now()));
|
||||||
tv_sec: dur.as_secs() as _,
|
|
||||||
tv_nsec: i64::from(dur.subsec_nanos()) as _,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build the poll set: fixed entries first, optional ones after.
|
// Build the interest set: fixed entries first, optional ones after.
|
||||||
let mut fds: Vec<PollFd<'_>> = Vec::with_capacity(5);
|
let mut interests: Vec<Interest<'_>> = Vec::with_capacity(5);
|
||||||
fds.push(PollFd::from_borrowed_fd(
|
interests.push(Interest {
|
||||||
mailbox.wakeup().as_fd(),
|
token: TOKEN_MAILBOX,
|
||||||
PollFlags::IN,
|
fd: mailbox.wakeup().as_fd(),
|
||||||
));
|
write: false,
|
||||||
fds.push(PollFd::from_borrowed_fd(self.stop.as_fd(), PollFlags::IN));
|
});
|
||||||
|
interests.push(Interest {
|
||||||
|
token: TOKEN_STOP,
|
||||||
|
fd: self.stop.as_fd(),
|
||||||
|
write: false,
|
||||||
|
});
|
||||||
let exec = td.backend.as_exec();
|
let exec = td.backend.as_exec();
|
||||||
let proc_idx = exec.process_fd().map(|fd| {
|
if let Some(fd) = exec.process_fd() {
|
||||||
fds.push(PollFd::from_borrowed_fd(fd, PollFlags::IN));
|
interests.push(Interest {
|
||||||
fds.len() - 1
|
token: TOKEN_PROCESS,
|
||||||
|
fd,
|
||||||
|
write: false,
|
||||||
});
|
});
|
||||||
let fp_idx = exec.flatpak_fd().map(|fd| {
|
}
|
||||||
fds.push(PollFd::from_borrowed_fd(fd, PollFlags::IN));
|
if let Some(fd) = exec.flatpak_fd() {
|
||||||
fds.len() - 1
|
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));
|
if exec.wants_write() {
|
||||||
fds.len() - 1
|
interests.push(Interest {
|
||||||
|
token: TOKEN_PTY_OUT,
|
||||||
|
fd: exec.pty_fd(),
|
||||||
|
write: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
match rustix::event::poll(&mut fds, timeout.as_ref()) {
|
if let Err(err) = reactor.wait(&interests, timeout, &mut ready) {
|
||||||
Ok(_) => {}
|
|
||||||
Err(rustix::io::Errno::INTR) => continue,
|
|
||||||
Err(err) => {
|
|
||||||
log::error!("io thread poll failed err={err}");
|
log::error!("io thread poll failed err={err}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
drop(interests);
|
||||||
let rev: Vec<PollFlags> = fds.iter().map(PollFd::revents).collect();
|
|
||||||
drop(fds);
|
|
||||||
|
|
||||||
let ready = |i: usize| !rev[i].is_empty();
|
|
||||||
|
|
||||||
// Stop requested.
|
// Stop requested.
|
||||||
if ready(1) {
|
if ready.contains(&TOKEN_STOP) {
|
||||||
self.stop.drain();
|
self.stop.drain();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mailbox has messages.
|
// The mailbox has messages.
|
||||||
if ready(0) {
|
if ready.contains(&TOKEN_MAILBOX) {
|
||||||
// When we wake up, we check the mailbox. Mailbox producers
|
// When we wake up, we check the mailbox. Mailbox producers
|
||||||
// should wake up our thread after publishing.
|
// should wake up our thread after publishing.
|
||||||
mailbox.wakeup().drain();
|
mailbox.wakeup().drain();
|
||||||
self.drain_mailbox(io, td);
|
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);
|
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();
|
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();
|
td.backend.as_exec_mut().flush_writes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,608 +3,24 @@
|
|||||||
//! End-to-end tests: real child processes on a real pty, driven through
|
//! End-to-end tests: real child processes on a real pty, driven through
|
||||||
//! `Termio` + `Thread` (IO thread) + the pty read thread, against a real
|
//! `Termio` + `Thread` (IO thread) + the pty read thread, against a real
|
||||||
//! `Terminal`.
|
//! `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::{
|
macro_rules! suite {
|
||||||
sync::{
|
($name:ident, $be:expr) => {
|
||||||
Arc,
|
mod $name {
|
||||||
Mutex,
|
const UNDER_TEST: ghostty_os::xev::Backend = $be;
|
||||||
atomic::{AtomicUsize, Ordering},
|
include!("exec_integration/suite.rs");
|
||||||
},
|
|
||||||
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)]
|
// `Kqueue` is not a candidate on Linux, so the reactor uses `poll(2)`.
|
||||||
struct RendererRec {
|
suite!(poll, ghostty_os::xev::Backend::Kqueue);
|
||||||
resizes: Mutex<Vec<Size>>,
|
#[cfg(target_os = "linux")]
|
||||||
reset_blinks: AtomicUsize,
|
suite!(epoll, ghostty_os::xev::Backend::Epoll);
|
||||||
}
|
#[cfg(target_os = "linux")]
|
||||||
|
suite!(io_uring, ghostty_os::xev::Backend::IoUring);
|
||||||
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"));
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in new issue
Block a user