fix: C action buffer lengths, byte-faithful callbacks, runtime finalize

Resolves the 8 gaps in docs/rewrite/notes/review-rv10-codex.md
(final-Z13.md):
- P1 memory safety: the C key-table action (and hovered-link/open-URL
  payloads) could advertise a length larger than the NUL-truncated
  allocation. A counted_bytes helper now passes the full bytes plus a
  trailing NUL with len == byte count, like Zig's backing slices; all
  C pointer+len structs were audited and tested to read exactly len.
- ghostty-bench args follow cli/args.zig (ordered duplicates, empty
  value resets, first bad value errors, parseBool spellings); the C
  benchmark tokenizer uses the ArgIteratorGeneral port.
- OSC 7 derived titles, desktop notifications and OSC 8 hover links
  forward raw bytes to the runtime; GTK converts only for display.
- C diagnostic getter exports original bytes; escaped surrogates report
  Utf8CannotEncodeSurrogateHalf; config float formatters print `nan`.
- Config::finalize_for(AppRuntime): GTK-only finalization (gtk-single-
  instance=detect) and termio's cgroup hooks apply only to the GTK
  runtime, so libghostty keeps `detect` like Zig.

Test Plan:
- cargo test --workspace --no-fail-fast (exit 0)
- cargo test -p ghostty-lib --features test-platform
- just check-wasm (macOS/Windows clippy re-run after the concurrent
  terminal changes land)
This commit is contained in:
ddidderr committed 2026-10-12 13:09:04 +02:00
1 parent 95fe02b55a
commit f2ccb04aa4
21 files changed
+479 -133

No files matched your search

+75 -12
View File
@@ -3818,6 +3818,47 @@ fn shell_from_env(value: &std::ffi::OsStr) -> Command {
Command::Shell(ghostty_os::osbytes::EncodedOs::from_os(value))
}
/// The application runtime (Zig `build_config.app_runtime`). It only
/// affects finalization (GTK-only defaults).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppRuntime {
/// No apprt: the embedded library (and wasm).
None,
/// GTK, the default on Linux and BSD.
Gtk,
}
const RUNTIME_DEFAULT: u8 = if cfg!(all(
unix,
not(target_vendor = "apple"),
not(target_family = "wasm")
)) {
1
} else {
0
};
static APP_RUNTIME: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(RUNTIME_DEFAULT);
/// The process-wide runtime used by [`Config::finalize`] (and by the
/// load/reload paths which finalize internally).
#[must_use]
pub fn app_runtime() -> AppRuntime {
match APP_RUNTIME.load(std::sync::atomic::Ordering::Relaxed) {
1 => AppRuntime::Gtk,
_ => AppRuntime::None,
}
}
/// Select the process-wide runtime. The embedded library selects
/// [`AppRuntime::None`] so `gtk-single-instance = detect` stays `detect`.
pub fn set_app_runtime(runtime: AppRuntime) {
APP_RUNTIME.store(
u8::from(runtime == AppRuntime::Gtk),
std::sync::atomic::Ordering::Relaxed,
);
}
impl Config {
/// The value of the config key in the form the C API exposes it (Zig:
/// `c_get.get`). `None` if there is no such key.
@@ -4478,6 +4519,12 @@ impl Config {
/// Call this once after you are done setting configuration. This
/// is idempotent but will waste memory if called multiple times.
pub fn finalize(&mut self) -> Result<(), Error> {
self.finalize_for(app_runtime())
}
/// [`Config::finalize`] for an explicit application runtime (Zig's
/// `build_config.app_runtime`, a build option there).
pub fn finalize_for(&mut self, runtime: AppRuntime) -> Result<(), Error> {
// We always load the theme first because it may set other fields
// in our config.
if let Some(theme) = self.theme.clone() {
@@ -4543,19 +4590,22 @@ impl Config {
wd.finalize();
self.working_directory = Some(wd);
// Apprt-specific defaults (GTK)
match self.gtk_single_instance {
GtkSingleInstance::True | GtkSingleInstance::False => {}
// Apprt-specific defaults
match runtime {
AppRuntime::None => {}
AppRuntime::Gtk => match self.gtk_single_instance {
GtkSingleInstance::True | GtkSingleInstance::False => {}
// For detection, we assume single instance unless we're
// in a CLI environment, then we disable single instance.
GtkSingleInstance::Detect => {
self.gtk_single_instance = if probable_cli {
GtkSingleInstance::False
} else {
GtkSingleInstance::True
};
}
// For detection, we assume single instance unless we're
// in a CLI environment, then we disable single instance.
GtkSingleInstance::Detect => {
self.gtk_single_instance = if probable_cli {
GtkSingleInstance::False
} else {
GtkSingleInstance::True
};
}
},
}
// Default our click interval
@@ -5433,6 +5483,19 @@ mod tests {
assert!(cfg.conditional_set.contains(&conditional::Key::Theme));
}
#[test]
fn gtk_single_instance_detect_only_finalizes_for_gtk() {
let mut cfg = Config::default();
cfg.load_iter(&mut it(&["--gtk-single-instance=detect"]))
.unwrap();
let mut none = cfg.clone();
none.finalize_for(AppRuntime::None).unwrap();
assert_eq!(none.gtk_single_instance, GtkSingleInstance::Detect);
let mut gtk = cfg;
gtk.finalize_for(AppRuntime::Gtk).unwrap();
assert_ne!(gtk.gtk_single_instance, GtkSingleInstance::Detect);
}
#[test]
fn compatibility_gtk_single_instance_desktop() {
let mut cfg = Config::default();
+19 -2
View File
@@ -4,11 +4,16 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StringError {
InvalidString,
/// `std.unicode.utf8Encode` of an escaped surrogate half.
Utf8CannotEncodeSurrogateHalf,
}
impl std::fmt::Display for StringError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("InvalidString")
f.write_str(match self {
StringError::InvalidString => "InvalidString",
StringError::Utf8CannotEncodeSurrogateHalf => "Utf8CannotEncodeSurrogateHalf",
})
}
}
@@ -88,7 +93,11 @@ pub fn parse_bytes(input: impl AsRef<[u8]>) -> Result<Vec<u8>, StringError> {
continue;
}
let cp = parse_escape_sequence(bytes, &mut i).ok_or(StringError::InvalidString)?;
let ch = char::from_u32(cp).ok_or(StringError::InvalidString)?;
let ch = char::from_u32(cp).ok_or(if (0xd800..=0xdfff).contains(&cp) {
StringError::Utf8CannotEncodeSurrogateHalf
} else {
StringError::InvalidString
})?;
out.extend_from_slice(ch.encode_utf8(&mut [0; 4]).as_bytes());
}
Ok(out)
@@ -200,6 +209,14 @@ mod tests {
#[test]
fn parse_invalid() {
assert_eq!(
parse("\\u{d800}"),
Err(StringError::Utf8CannotEncodeSurrogateHalf)
);
assert_eq!(
crate::cli_args::ArgError::from(StringError::Utf8CannotEncodeSurrogateHalf),
crate::cli_args::ArgError::other("Utf8CannotEncodeSurrogateHalf")
);
assert_eq!(parse("\\q"), Err(StringError::InvalidString));
assert_eq!(parse("\\"), Err(StringError::InvalidString));
assert_eq!(parse("\\u{}"), Err(StringError::InvalidString));
+8 -1
View File
@@ -738,7 +738,7 @@ impl ConfigField for RepeatableFontVariation {
&format!(
"{}={}",
ghostty_os::osbytes::bytes_to_config_string(&id),
v.value
ghostty_os::zig_fmt::format_float_d(v.value)
),
)?;
}
@@ -1508,6 +1508,13 @@ mod tests {
assert_eq!(list.list, vec![v(b"wght", 200.0), v(b"slnt", -15.0)]);
}
#[test]
fn format_nan_like_zig() {
let mut list = RepeatableFontVariation::default();
parse(&mut list, "wght=NaN").unwrap();
assert_eq!(fe(&list), "a = wght=nan\n");
}
#[test]
fn format_config_single() {
let mut list = RepeatableFontVariation::default();
+10 -1
View File
@@ -568,7 +568,9 @@ impl QuickTerminalSizeValue {
fn format(self, out: &mut String) {
match self {
Self::Percentage(v) => out.push_str(&format!("{v}%")),
Self::Percentage(v) => {
out.push_str(&format!("{}%", ghostty_os::zig_fmt::format_float_d(v)));
}
Self::Pixels(v) => out.push_str(&format!("{v}px")),
}
}
@@ -1287,6 +1289,13 @@ config_packed! {
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::approx_constant)]
mod tests {
#[test]
fn quick_terminal_size_value_formats_nan_like_zig() {
let mut out = String::new();
QuickTerminalSizeValue::Percentage(f32::NAN).format(&mut out);
assert_eq!(out, "nan%");
}
use super::*;
use crate::formatter::format_entry;