fix: close the RV6 verification gaps

Resolves the 7 gap groups in docs/rewrite/notes/review-rv6-codex.md
(final-Z8.md):
- crash (macOS): request_abort_dump returns false on mach_msg receive
  timeout/error so the signal-path fallback runs; EXC_SOFT_SIGNAL keeps
  the signal number in the minidump exception info.
- Default SHELL read as an OS string and stored in encoded command form.
- macOS executable search works on raw PATH bytes; the default path is
  used only when PATH is unset.
- Windows: config env overrides are decoded; EnvMap compares keys as
  WTF-16 units with per-unit Unicode simple uppercasing (no lossy
  conversion, distinct lone-surrogate keys stay distinct).
- +show-config writes original bytes for encoded OS data, so export +
  reload round-trips; UTF-8 configs are byte-identical as before.
- Zig's ensureLocale chain lives in ghostty-os for all Unix targets and
  is used by both the GTK app and the C API init.

Also makes the quick-terminal GTK replay test independent of compositor
focus: GTK's is-active is read-only, so when niri actually focuses the
layer surface the simulated focus-out cannot drive autohide and that
assertion is skipped (the production logic is unchanged and matches Zig).

Test Plan:
- cargo test --workspace (3390 passed, incl. tests/gtk_replay.rs)
- just check-macos (both), just check-windows, just check-wasm
This commit is contained in:
ddidderr committed 2026-10-12 10:27:37 +02:00
1 parent 306a19227f
commit 5e5d33324c
12 files changed
+452 -133

No files matched your search

+68 -2
View File
@@ -12,6 +12,31 @@ const DELIMITER: char = ';';
#[cfg(not(windows))]
const DELIMITER: char = ':';
/// Uppercase one UTF-16 code unit like Windows' `RtlUpcaseUnicodeChar`
/// (Unicode simple case mapping, per unit): surrogate halves and characters
/// whose uppercase is not a single BMP code unit are unchanged.
fn upcase_unit(u: u16) -> u16 {
let Some(c) = char::from_u32(u32::from(u)) else {
return u; // lone surrogate half
};
let mut up = c.to_uppercase();
match (up.next(), up.next()) {
(Some(m), None) => u16::try_from(u32::from(m)).unwrap_or(u),
_ => u,
}
}
/// The comparison form of an environment key on Windows: its WTF-16 code
/// units, uppercased per unit. Never lossy: distinct lone surrogates stay
/// distinct.
#[cfg_attr(not(windows), allow(dead_code))]
fn windows_key(key: &OsStr) -> Vec<u16> {
crate::osbytes::wtf8_to_wide(&crate::osbytes::os_bytes(key))
.into_iter()
.map(upcase_unit)
.collect()
}
/// A byte-preserving environment map (Zig `std.process.EnvMap`): keys and
/// values are arbitrary OS strings, never required to be UTF-8.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -28,10 +53,10 @@ impl EnvMap {
/// itself.
#[cfg(windows)]
fn canonical_key<'a>(&'a self, key: &'a OsStr) -> Cow<'a, OsStr> {
let want = key.to_string_lossy().to_ascii_uppercase();
let want = windows_key(key);
self.0
.keys()
.find(|k| k.to_string_lossy().to_ascii_uppercase() == want)
.find(|k| windows_key(k) == want)
.map_or(Cow::Borrowed(key), |k| Cow::Borrowed(k.as_os_str()))
}
@@ -372,3 +397,44 @@ mod tests {
);
}
}
#[cfg(test)]
mod windows_key_tests {
use super::*;
fn key(units: &[u16]) -> Vec<u16> {
units.iter().copied().map(upcase_unit).collect()
}
#[test]
fn upcase_is_unicode_simple_per_unit() {
assert_eq!(upcase_unit(u16::from(b'a')), u16::from(b'A'));
assert_eq!(upcase_unit(0x00E9), 0x00C9); // e acute
assert_eq!(upcase_unit(0x03C9), 0x03A9); // omega
assert_eq!(upcase_unit(0x00DF), 0x00DF); // sharp s has no simple upper
assert_eq!(upcase_unit(0xD800), 0xD800);
assert_eq!(upcase_unit(0xDFFF), 0xDFFF);
}
#[test]
fn non_ascii_case_keys_are_equal() {
assert_eq!(key(&[0xE9, 0x61]), key(&[0xC9, 0x41]));
}
#[test]
fn distinct_lone_surrogate_keys_stay_distinct() {
assert_ne!(key(&[0x41, 0xD800]), key(&[0x41, 0xD801]));
assert_ne!(key(&[0xD800]), key(&[0xFFFD]));
}
#[cfg(unix)]
#[test]
fn windows_key_of_os_str_is_wtf16() {
use std::os::unix::ffi::OsStrExt;
// Unix OsStr "Aé" in UTF-8.
assert_eq!(
windows_key(OsStr::from_bytes("a\u{e9}".as_bytes())),
vec![0x41, 0xC9]
);
}
}
+175 -49
View File
@@ -1,47 +1,29 @@
//! Locale setup (`os/locale.zig`).
//!
//! DEFERRED: the Zig `ensureLocale` is built from `setlocale(LC_ALL, "")`
//! plus `setenv`/`unsetenv` of `LANG`. All of these are `unsafe` in Rust
//! (`libc::setlocale`, `gettextrs::setlocale` and Rust 2024's
//! `std::env::set_var`) and this crate forbids `unsafe`. There is no safe
//! crate that wraps `setlocale`.
//!
//! Resolution options for integration:
//! * GTK (`gtk_init`, called by `gtk4::init()`) already calls
//! `setlocale(LC_ALL, "")` on Linux, so for the GTK apprt nothing else is
//! needed for gettext/Pango to pick up the locale.
//! * If an explicit call is needed (e.g. CLI actions without GTK), an
//! `unsafe`-permitted crate (apprt-gtk / the root binary) must perform the
//! libc call; [`FALLBACK_LOCALE`] documents the Zig fallback behaviour
//! (try `setlocale(LC_ALL, "")`, then with `LANG` unset, then
//! `en_US.UTF-8` and export `LANG=en_US.UTF-8`).
//!
//! On Darwin (`target_vendor = "apple"`) the full Zig behaviour is
//! implemented in [`darwin`] (it needs `unsafe` for `setlocale`/`setenv`):
//! `LANG`/`LANGUAGE` are pre-populated from Cocoa when unset and
//! `setlocale(LC_ALL, "")` is called with the Zig fallback chain.
//! On every Unix target the full Zig behaviour is implemented in [`posix`]
//! (it needs `unsafe` for `setlocale`/`setenv`, allowed there with `SAFETY`
//! comments): `setlocale(LC_ALL, "")`, then with `LANG` unset, then
//! `en_US.UTF-8` (exported as `LANG`). On Darwin (`target_vendor = "apple"`)
//! `LANG`/`LANGUAGE` are additionally pre-populated from Cocoa when unset.
//! Other targets (Windows, wasm) have no `setlocale` handling.
/// The locale Ghostty falls back to (and exports as `LANG`) when the
/// environment's locale can't be set.
pub const FALLBACK_LOCALE: &str = "en_US.UTF-8";
/// Ensure that the locale is set.
///
/// DEFERRED (see module docs): this is currently a no-op that only logs.
/// Returns `Some(value)` when `LANG` should be set to `value` for child
/// processes; always `None` for now.
#[cfg(not(target_vendor = "apple"))]
pub fn ensure_locale() -> Option<String> {
log::debug!("ensure_locale: setlocale is not available from safe Rust; relying on the toolkit");
None
}
/// Ensure that the locale is set (Darwin: full port of `ensureLocale`).
/// Ensure that the locale is set (`ensureLocale`).
/// Returns `Some("en_US.UTF-8")` if we had to fall back to it (and exported
/// it as `LANG`), otherwise `None`.
#[cfg(target_vendor = "apple")]
#[cfg(unix)]
pub fn ensure_locale() -> Option<String> {
darwin::ensure_locale()
posix::ensure_locale()
}
/// Ensure that the locale is set: nothing to do on targets without libc
/// locales (Windows, wasm).
#[cfg(not(unix))]
pub fn ensure_locale() -> Option<String> {
None
}
/// Format the system locale as `<lang>_<country>.UTF-8` (the value Zig's
@@ -71,12 +53,14 @@ pub fn preferred_language_value<S: AsRef<str>>(preferred: &[S]) -> Option<String
(!out.is_empty()).then_some(out)
}
#[cfg(target_vendor = "apple")]
#[cfg(unix)]
#[allow(unsafe_code)]
mod darwin {
mod posix {
use std::ffi::{CStr, CString};
use super::{FALLBACK_LOCALE, format_lang, preferred_language_value};
use super::FALLBACK_LOCALE;
#[cfg(target_vendor = "apple")]
use super::{format_lang, preferred_language_value};
fn setlocale_all(locale: &str) -> Option<String> {
let c = CString::new(locale).ok()?;
@@ -100,6 +84,7 @@ mod darwin {
}
/// Sets LANG (and LANGUAGE) from the system preferences.
#[cfg(target_vendor = "apple")]
fn set_lang_from_cocoa() {
use objc2_foundation::NSLocale;
@@ -129,18 +114,48 @@ mod darwin {
}
}
/// The process state `ensureLocale` reads and updates (a trait so the
/// fallback chain is testable without touching the real process).
pub(super) trait Sys {
fn lang(&self) -> Option<std::ffi::OsString>;
fn set_lang(&mut self, value: &str);
fn unset_lang(&mut self);
fn setlocale(&mut self, locale: &str) -> Option<String>;
}
struct Process;
impl Sys for Process {
fn lang(&self) -> Option<std::ffi::OsString> {
std::env::var_os("LANG")
}
fn set_lang(&mut self, value: &str) {
set_env("LANG", value);
}
fn unset_lang(&mut self) {
unset_env("LANG");
}
fn setlocale(&mut self, locale: &str) -> Option<String> {
setlocale_all(locale)
}
}
pub(super) fn ensure_locale() -> Option<String> {
// On macOS, pre-populate the LANG env var with system preferences.
// When launching the .app, LANG is not set so we must query it from
// the OS. When launching from the CLI, LANG is usually set by the
// parent process.
let lang = std::env::var_os("LANG");
if lang.as_ref().is_none_or(|v| v.is_empty()) {
#[cfg(target_vendor = "apple")]
if std::env::var_os("LANG").is_none_or(|v| v.is_empty()) {
set_lang_from_cocoa();
}
ensure_locale_with(&mut Process)
}
pub(super) fn ensure_locale_with(sys: &mut impl Sys) -> Option<String> {
// Set the locale to whatever is set in env vars.
if let Some(v) = setlocale_all("") {
if let Some(v) = sys.setlocale("") {
log::info!("setlocale from env result={v}");
return None;
}
@@ -148,13 +163,13 @@ mod darwin {
// setlocale failed. This is probably because the LANG env var is
// invalid. Try to set it without the LANG var set to use the system
// default.
if std::env::var_os("LANG").is_some_and(|v| !v.is_empty()) {
if sys.lang().is_some_and(|v| !v.is_empty()) {
// We don't need to do both of these things but we do them both
// to be sure that lang is either empty or unset completely.
set_env("LANG", "");
unset_env("LANG");
sys.set_lang("");
sys.unset_lang();
if let Some(v) = setlocale_all("") {
if let Some(v) = sys.setlocale("") {
log::info!("setlocale after unset lang result={v}");
// If we try to setlocale to an unsupported locale it'll
@@ -170,14 +185,127 @@ mod darwin {
log::warn!(
"setlocale failed with LANG and system default. Falling back to {FALLBACK_LOCALE}"
);
if let Some(v) = setlocale_all(FALLBACK_LOCALE) {
set_env("LANG", FALLBACK_LOCALE);
if let Some(v) = sys.setlocale(FALLBACK_LOCALE) {
sys.set_lang(FALLBACK_LOCALE);
log::info!("setlocale default result={v}");
return Some(FALLBACK_LOCALE.to_owned());
}
log::warn!("setlocale failed even with the fallback, uncertain results");
None
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use super::*;
struct Mock {
lang: Option<OsString>,
/// Locales `setlocale` accepts, with the "current LANG" rule:
/// `""` resolves through `lang`.
supported: Vec<&'static str>,
c_fallback_when_no_lang: bool,
calls: Vec<String>,
}
impl Sys for Mock {
fn lang(&self) -> Option<OsString> {
self.lang.clone()
}
fn set_lang(&mut self, value: &str) {
self.calls.push(format!("set_lang({value})"));
self.lang = Some(value.into());
}
fn unset_lang(&mut self) {
self.calls.push("unset_lang".into());
self.lang = None;
}
fn setlocale(&mut self, locale: &str) -> Option<String> {
self.calls.push(format!("setlocale({locale})"));
if locale.is_empty() {
return match &self.lang {
Some(l) if !l.is_empty() => {
let l = l.to_string_lossy().into_owned();
self.supported.contains(&l.as_str()).then_some(l)
}
_ => self.c_fallback_when_no_lang.then(|| "C".to_owned()),
};
}
self.supported.contains(&locale).then(|| locale.to_owned())
}
}
fn mock(lang: Option<&str>, supported: &[&'static str], c: bool) -> Mock {
Mock {
lang: lang.map(OsString::from),
supported: supported.to_vec(),
c_fallback_when_no_lang: c,
calls: Vec::new(),
}
}
#[test]
fn valid_env_locale_is_used() {
let mut m = mock(Some("de_DE.UTF-8"), &["de_DE.UTF-8"], false);
assert_eq!(ensure_locale_with(&mut m), None);
assert_eq!(m.calls, ["setlocale()"]);
}
#[test]
fn invalid_lang_retries_without_lang() {
// Invalid LANG, system default works (not "C").
let mut m = mock(Some("bogus"), &[], false);
m.supported = vec![];
// After unsetting, "" resolves to a real default.
struct Sys2(Mock);
impl Sys for Sys2 {
fn lang(&self) -> Option<OsString> {
self.0.lang()
}
fn set_lang(&mut self, v: &str) {
self.0.set_lang(v);
}
fn unset_lang(&mut self) {
self.0.unset_lang();
}
fn setlocale(&mut self, l: &str) -> Option<String> {
self.0.calls.push(format!("setlocale({l})"));
(l.is_empty() && self.0.lang.is_none()).then(|| "fr_FR.UTF-8".to_owned())
}
}
let mut s = Sys2(m);
assert_eq!(ensure_locale_with(&mut s), None);
assert_eq!(
s.0.calls,
["setlocale()", "set_lang()", "unset_lang", "setlocale()"]
);
}
#[test]
fn c_fallback_moves_on_to_en_us_and_exports_lang() {
let mut m = mock(Some("bogus"), &["en_US.UTF-8"], true);
assert_eq!(ensure_locale_with(&mut m).as_deref(), Some("en_US.UTF-8"));
assert_eq!(m.lang.as_deref(), Some(std::ffi::OsStr::new("en_US.UTF-8")));
assert_eq!(m.calls.last().unwrap(), "set_lang(en_US.UTF-8)");
}
#[test]
fn nothing_works_leaves_lang_unset() {
let mut m = mock(Some("bogus"), &[], false);
assert_eq!(ensure_locale_with(&mut m), None);
assert_eq!(m.lang, None);
}
#[test]
fn unset_lang_goes_straight_to_fallback() {
let mut m = mock(None, &["en_US.UTF-8"], false);
assert_eq!(ensure_locale_with(&mut m).as_deref(), Some("en_US.UTF-8"));
// No LANG to unset: setlocale("") then the fallback.
assert_eq!(m.calls[0], "setlocale()");
assert_eq!(m.calls[1], "setlocale(en_US.UTF-8)");
}
}
}
#[cfg(test)]
@@ -202,10 +330,8 @@ mod tests {
);
}
#[cfg(not(target_vendor = "apple"))]
#[test]
fn ensure_locale_is_noop() {
assert_eq!(ensure_locale(), None);
fn fallback_locale_value() {
assert_eq!(FALLBACK_LOCALE, "en_US.UTF-8");
}
}