fix: URL opener and +show-face output match Ghostty, register fixes

Resolves the parity items of docs/rewrite/notes/review-rv15-codex.md
(final-Z18.md):
- os::open inherits stdin like Zig's Child.init, strips LD_LIBRARY_PATH
  only in Snap builds (new `snap` cargo feature, off by default, like
  Zig's snap build option) and bounds output collection to 50 KiB per
  stream (StdoutStreamTooLong/StderrStreamTooLong).
- +show-face writes diagnostics and lookup lines in the exact fragments
  Zig's formatter produces, so buffer drains happen at the same byte
  positions (0 diffs vs /usr/bin/ghostty over 0-44 bad options for
  three codepoints); its tests no longer parse the test runner's argv.
- DEVIANT_FIXES.md: five entries reworded and four removed that were no
  longer deviations (verified against code and oracle).

Test Plan:
- cargo test -p ghostty-os open; cargo test -p ghostty-cli show_face
This commit is contained in:
ddidderr committed 2026-10-12 15:37:24 +02:00
1 parent 0dffc62bdf
commit 465a85b6d5
6 files changed
+296 -53

No files matched your search

+141 -16
View File
@@ -7,7 +7,7 @@ use ghostty_config::{
config::Config,
config_enum,
config_struct,
diagnostics::{DiagnosticList, Location},
diagnostics::{Diagnostic, DiagnosticList, Location},
};
use ghostty_font::{
self as font,
@@ -94,7 +94,7 @@ pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let sink: Sink = stdout_sink();
let mut stdout = ZigWriter::new(sink.clone(), 1024);
let mut stderr = ZigWriter::new(sink, 1024);
let result = run_args(&opts, &mut stdout, &mut stderr);
let result = run_args(&opts, &mut stdout, &mut stderr, &Config::load);
let _ = stdout.flush();
let _ = stderr.flush();
result
@@ -104,6 +104,7 @@ fn run_args(
opts: &Options,
stdout: &mut dyn std::io::Write,
stderr: &mut dyn std::io::Write,
load_config: &dyn Fn() -> Result<Config, ghostty_config::config::Error>,
) -> Result<u8, Error> {
// Print out any diagnostics, unless it's likely that the diagnostic was
// generated trying to parse a "normal" configuration setting. Exit with an
@@ -115,9 +116,7 @@ fn run_args(
continue;
}
if FIELDS.contains(&d.key.as_str()) {
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&format!(
"config error: {d}\n"
)))?;
write_diagnostic(stderr, d)?;
exit = true;
}
}
@@ -133,7 +132,7 @@ fn run_args(
return Ok(1);
}
let config = match Config::load() {
let config = match load_config() {
Ok(c) => c,
Err(err) => {
write!(stderr, "Unable to load config: {err}")?;
@@ -150,9 +149,7 @@ fn run_args(
{
continue;
}
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&format!(
"config error: {d}\n"
)))?;
write_diagnostic(stderr, d)?;
}
let font_grid_set = match SharedGridSet::new() {
@@ -227,7 +224,7 @@ fn lookup(
) -> Result<Option<u8>, Error> {
let ch = char::from_u32(cp).unwrap_or('\u{FFFD}');
let Ok(Some(idx)) = grid.get_index(cp, style, presentation) else {
writeln!(stdout, "U+{cp:02X} \u{ab} {ch} \u{bb} not found.")?;
write_lookup(stdout, cp, ch, " \u{bb} not found.\n", None)?;
return Ok(None);
};
@@ -235,9 +232,12 @@ fn lookup(
let face = match st.resolver.collection.get_face(idx) {
Ok(f) => f,
Err(GetFaceError::Entry(EntryError::SpecialHasNoFace)) => {
writeln!(
write_lookup(
stdout,
"U+{cp:02X} \u{ab} {ch} \u{bb} is handled by Ghostty's internal sprites."
cp,
ch,
" \u{bb} is handled by Ghostty's internal sprites.\n",
None,
)?;
return Ok(None);
}
@@ -248,18 +248,100 @@ fn lookup(
};
let name = face.name();
writeln!(
write_lookup(
stdout,
"U+{cp:02X} \u{ab} {ch} \u{bb} found in face \u{201c}{name}\u{201d}."
cp,
ch,
" \u{bb} found in face \u{201c}",
Some(name.as_bytes()),
)?;
Ok(None)
}
/// Zig's `Writer.print` issues one `writeAll` per literal segment and per
/// formatted argument, and an aligned integer writes its padding separately
/// from its digits. Where the fixed-size buffer drains depends on those
/// fragments, so every message below is written in Zig's exact fragments.
fn write_parts(w: &mut dyn std::io::Write, parts: &[&[u8]]) -> std::io::Result<()> {
for part in parts {
w.write_all(part)?;
}
Ok(())
}
/// `stderr.print("config error: {f}\n", .{diagnostic})` in Zig
/// (`cli/diagnostics.zig:19-37` formats through nested `print` calls).
fn write_diagnostic(w: &mut dyn std::io::Write, d: &Diagnostic) -> std::io::Result<()> {
use ghostty_os::osbytes::config_str_to_bytes as enc;
let mut parts: Vec<Vec<u8>> = vec![b"config error: ".to_vec()];
match &d.location {
Location::None => {}
Location::Cli(index) => {
parts.push(b"cli:".to_vec());
parts.push(index.to_string().into_bytes());
parts.push(b":".to_vec());
}
Location::File { path, line } => {
parts.push(enc(path));
parts.push(b":".to_vec());
parts.push(line.to_string().into_bytes());
parts.push(b":".to_vec());
}
}
if !d.key.is_empty() {
parts.push(enc(&d.key));
parts.push(b": ".to_vec());
} else if d.location != Location::None {
parts.push(b" ".to_vec());
}
parts.push(enc(&d.message));
parts.push(b"\n".to_vec());
let refs: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect();
write_parts(w, &refs)
}
/// `U+{0X:0>2} \u{ab} {0u} <tail>` (optionally followed by `name` and the
/// closing `\u{201d}.\n` for the "found in face" line).
fn write_lookup(
w: &mut dyn std::io::Write,
cp: u32,
ch: char,
tail: &str,
name: Option<&[u8]>,
) -> std::io::Result<()> {
let hex = format!("{cp:X}");
let mut buf = [0u8; 4];
let mut parts: Vec<&[u8]> = vec![b"U+"];
if hex.len() < 2 {
parts.push(b"0");
}
parts.push(hex.as_bytes());
parts.push(" \u{ab} ".as_bytes());
parts.push(ch.encode_utf8(&mut buf).as_bytes());
parts.push(tail.as_bytes());
if let Some(name) = name {
parts.push(name);
parts.push("\u{201d}.\n".as_bytes());
}
write_parts(w, &parts)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
/// Load the config from explicit args instead of the test runner's argv.
fn test_config() -> Result<Config, ghostty_config::config::Error> {
let mut config = Config::new_default();
let args: [&[u8]; 1] = [b"--config-default-files=false"];
config
.load_iter(&mut ghostty_config::cli_args::slice_iterator(&args))
.map_err(|e| ghostty_config::config::Error::Other(e.to_string()))?;
config.finalize()?;
Ok(config)
}
fn parse(args: &[&[u8]]) -> Options {
let mut full: Vec<&[u8]> = vec![b"ghostty"];
full.extend_from_slice(args);
@@ -287,7 +369,7 @@ mod tests {
let o = parse(&[b"--cp=4294967295"]);
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run_args(&o, &mut out, &mut err).unwrap(), 1);
assert_eq!(run_args(&o, &mut out, &mut err, &test_config).unwrap(), 1);
let text = String::from_utf8(err).unwrap();
assert!(
text.starts_with("config error: cli:") && text.contains("cp: invalid value"),
@@ -309,7 +391,7 @@ mod tests {
let mut err = Vec::new();
// Without a usable font setup this can fail earlier; only assert the
// Zig message when the grid was available.
let rc = run_args(&o, &mut out, &mut err).unwrap();
let rc = run_args(&o, &mut out, &mut err, &test_config).unwrap();
let err = String::from_utf8(err).unwrap();
if err.contains("Unable to") && !err.contains("as unicode") {
return;
@@ -317,4 +399,47 @@ mod tests {
assert_eq!(rc, 1);
assert_eq!(err, "Unable to parse string as unicode: error.InvalidUtf8");
}
#[derive(Clone, Default)]
struct Mem(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
impl std::io::Write for Mem {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.0.borrow_mut().extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn diagnostics_drain_at_zig_fragment_boundaries() {
use crate::zig_writer::stream_sink;
let mem = Mem::default();
let mut w = ZigWriter::new(stream_sink(mem.clone()), 20);
let d = Diagnostic {
location: Location::Cli(0),
key: "cp".into(),
message: "invalid value".into(),
};
write_diagnostic(&mut w, &d).unwrap();
// Zig writes "config error: ", "cli:", "0", ":", "cp" (overflows: the
// buffer drains together with "cp"), then ": ", message and "\n".
assert_eq!(&*mem.0.borrow(), b"config error: cli:0:cp");
w.flush().unwrap();
assert_eq!(&*mem.0.borrow(), b"config error: cli:0:cp: invalid value\n");
}
#[test]
fn lookup_line_pads_hex_and_drains_per_fragment() {
use crate::zig_writer::stream_sink;
let mem = Mem::default();
let mut w = ZigWriter::new(stream_sink(mem.clone()), 1024);
write_lookup(&mut w, 0xA, '\n', " \u{bb} not found.\n", None).unwrap();
w.flush().unwrap();
assert_eq!(
String::from_utf8(mem.0.borrow().clone()).unwrap(),
"U+0A \u{ab} \n \u{bb} not found.\n"
);
}
}
+3
View File
@@ -51,6 +51,9 @@ crossbeam-channel = "0.5.17"
[features]
# Mirrors Zig's `wasm_shared` build option (SharedArrayBuffer-backed memory).
wasm-shared = []
# Mirrors Zig's `snap` build option (`-Dsnap`, off by default): the URL
# opener drops `LD_LIBRARY_PATH` from the child environment.
snap = []
# Same as `[workspace.lints]` except `unsafe_code` is denied instead of
# forbidden: the wasm JS import blocks (`src/wasm`) need `unsafe`.
+132 -28
View File
@@ -1,7 +1,7 @@
use std::{
ffi::OsStr,
io,
process::{Command, Stdio},
io::{self, Read},
process::{Child, Command, Stdio},
};
/// Kind of URL being opened (`apprt.action.OpenUrl.Kind`). On Linux the
@@ -31,25 +31,9 @@ pub enum OpenUrlKind {
/// apprt for Ghostty, you should consider doing something special-cased
/// for your platform.
pub fn open(kind: OpenUrlKind, url: &OsStr) -> io::Result<()> {
let mut cmd = open_command(kind, url)?;
// Pipe stdout/stderr so we can collect output from the command.
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at
// the snap's bundled libraries. Leaking this into child process can
// can be problematic, so let's drop it from the env.
// TODO(integration): Zig does this only for `build_config.snap` builds;
// we do it when running inside a snap (`SNAP` is set).
if std::env::var_os("SNAP").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
}
// Spawn the process on our same thread so we can detect failure
// quickly.
let child = cmd.spawn()?;
let child = spawn_child(open_command(kind, url)?)?;
// Create a thread that handles collecting output and reaping
// the process. This is done in a separate thread because SOME
@@ -61,6 +45,26 @@ pub fn open(kind: OpenUrlKind, url: &OsStr) -> io::Result<()> {
Ok(())
}
/// Configure and spawn the opener. Like Zig's `Child.init`, stdin is
/// inherited.
fn spawn_child(mut cmd: Command) -> io::Result<Child> {
// Pipe stdout/stderr so we can collect output from the command.
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
strip_snap_env(&mut cmd);
cmd.spawn()
}
/// In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at
/// the snap's bundled libraries. Leaking this into child process can
/// can be problematic, so let's drop it from the env. Zig does this only
/// for `build_config.snap` builds (the `snap` cargo feature here, off by
/// default).
fn strip_snap_env(cmd: &mut Command) {
if cfg!(feature = "snap") {
cmd.env_remove("LD_LIBRARY_PATH");
}
}
/// Build the platform's "open URL" command (Zig `open`'s switch over
/// `builtin.os.tag`).
#[cfg(windows)]
@@ -107,24 +111,58 @@ fn open_command(_kind: OpenUrlKind, _url: &OsStr) -> io::Result<Command> {
Err(io::Error::new(io::ErrorKind::Unsupported, "unimplemented"))
}
fn open_thread(child: std::process::Child) {
// 50 KiB is the default value used by std.process.Child.run and should
// be enough to get the output we care about.
const OUTPUT_MAX_SIZE: usize = 50 * 1024;
/// 50 KiB is the default value used by std.process.Child.run and should
/// be enough to get the output we care about.
const OUTPUT_MAX_SIZE: usize = 50 * 1024;
let output = match child.wait_with_output() {
Ok(o) => o,
/// Read at most [`OUTPUT_MAX_SIZE`] bytes; more is an error
/// (`error.StdoutStreamTooLong` / `StderrStreamTooLong` in Zig).
fn read_bounded(mut r: impl Read, name: &str) -> io::Result<Vec<u8>> {
let mut buf = Vec::new();
(&mut r)
.take(OUTPUT_MAX_SIZE as u64 + 1)
.read_to_end(&mut buf)?;
if buf.len() > OUTPUT_MAX_SIZE {
return Err(io::Error::other(format!("{name} stream too long")));
}
Ok(buf)
}
/// Collect both streams concurrently, bounded per stream.
fn collect_output(child: &mut Child) -> io::Result<(Vec<u8>, Vec<u8>)> {
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let out_thread = std::thread::spawn(move || match stdout {
Some(r) => read_bounded(r, "stdout"),
None => Ok(Vec::new()),
});
let err = match stderr {
Some(r) => read_bounded(r, "stderr"),
None => Ok(Vec::new()),
};
let out = out_thread
.join()
.unwrap_or_else(|_| Err(io::Error::other("stdout reader panicked")));
Ok((out?, err?))
}
fn open_thread(mut child: Child) {
let stderr = match collect_output(&mut child) {
Ok((_stdout, stderr)) => stderr,
Err(e) => {
log::warn!("error waiting for open command err={e}");
log::warn!("error collecting open command output err={e}");
return;
}
};
if let Err(e) = child.wait() {
log::warn!("error waiting for open command err={e}");
return;
}
// If we have any stderr output we log it. This makes it easier for
// users to debug why some open commands may not work as expected.
let stderr = &output.stderr[..output.stderr.len().min(OUTPUT_MAX_SIZE)];
if !stderr.is_empty() {
log::warn!("wait stderr={}", String::from_utf8_lossy(stderr));
log::warn!("wait stderr={}", String::from_utf8_lossy(&stderr));
}
}
@@ -167,4 +205,70 @@ mod tests {
assert_eq!(err.kind(), io::ErrorKind::NotFound);
});
}
#[test]
fn output_collection_is_bounded_per_stream() {
let mut ok = Command::new("/bin/sh")
.args([
"-c",
"PATH=/usr/bin:/bin; head -c 51200 /dev/zero; head -c 51200 /dev/zero >&2",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let (out, err) = collect_output(&mut ok).unwrap();
assert_eq!((out.len(), err.len()), (OUTPUT_MAX_SIZE, OUTPUT_MAX_SIZE));
ok.wait().unwrap();
for fd in ["", ">&2"] {
let script = format!("PATH=/usr/bin:/bin; head -c 51201 /dev/zero {fd}");
let mut big = Command::new("/bin/sh")
.args(["-c", &script])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
assert!(collect_output(&mut big).is_err(), "{fd}");
big.wait().unwrap();
}
}
#[test]
fn snap_env_is_stripped_only_for_snap_builds() {
let mut cmd = Command::new("true");
strip_snap_env(&mut cmd);
let removed = cmd
.get_envs()
.any(|(k, v)| k == "LD_LIBRARY_PATH" && v.is_none());
assert_eq!(removed, cfg!(feature = "snap"));
}
/// The opener inherits stdin (Zig `Child.init` default).
#[test]
fn opener_inherits_stdin() {
use std::io::Write as _;
const CHILD: &str = "GHOSTTY_TEST_OPEN_STDIN_CHILD";
if std::env::var_os(CHILD).is_some() {
let child = spawn_child(Command::new("/bin/cat")).unwrap();
let out = child.wait_with_output().unwrap();
print!("got:{}", String::from_utf8_lossy(&out.stdout));
return;
}
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"open::tests::opener_inherits_stdin",
"--nocapture",
])
.env(CHILD, "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"hello").unwrap();
let out = child.wait_with_output().unwrap();
assert!(String::from_utf8_lossy(&out.stdout).contains("got:hello"));
}
}