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:
1 parent
0dffc62bdf
commit
465a85b6d5
6 files changed
+296
-53
No files matched your search
@@ -12,6 +12,8 @@ path = "src/main.rs"
|
|||||||
# Mirrors upstream's `-Dsentry` build option (off by default on Linux):
|
# Mirrors upstream's `-Dsentry` build option (off by default on Linux):
|
||||||
# enables the local crash-report handler (`ghostty-crash`).
|
# enables the local crash-report handler (`ghostty-crash`).
|
||||||
sentry = ["ghostty-crash/sentry"]
|
sentry = ["ghostty-crash/sentry"]
|
||||||
|
# Mirrors upstream's `-Dsnap` build option (off by default).
|
||||||
|
snap = ["ghostty-os/snap"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ghostty-core.workspace = true
|
ghostty-core.workspace = true
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use ghostty_config::{
|
|||||||
config::Config,
|
config::Config,
|
||||||
config_enum,
|
config_enum,
|
||||||
config_struct,
|
config_struct,
|
||||||
diagnostics::{DiagnosticList, Location},
|
diagnostics::{Diagnostic, DiagnosticList, Location},
|
||||||
};
|
};
|
||||||
use ghostty_font::{
|
use ghostty_font::{
|
||||||
self as font,
|
self as font,
|
||||||
@@ -94,7 +94,7 @@ pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
|||||||
let sink: Sink = stdout_sink();
|
let sink: Sink = stdout_sink();
|
||||||
let mut stdout = ZigWriter::new(sink.clone(), 1024);
|
let mut stdout = ZigWriter::new(sink.clone(), 1024);
|
||||||
let mut stderr = ZigWriter::new(sink, 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 _ = stdout.flush();
|
||||||
let _ = stderr.flush();
|
let _ = stderr.flush();
|
||||||
result
|
result
|
||||||
@@ -104,6 +104,7 @@ fn run_args(
|
|||||||
opts: &Options,
|
opts: &Options,
|
||||||
stdout: &mut dyn std::io::Write,
|
stdout: &mut dyn std::io::Write,
|
||||||
stderr: &mut dyn std::io::Write,
|
stderr: &mut dyn std::io::Write,
|
||||||
|
load_config: &dyn Fn() -> Result<Config, ghostty_config::config::Error>,
|
||||||
) -> Result<u8, Error> {
|
) -> Result<u8, Error> {
|
||||||
// Print out any diagnostics, unless it's likely that the diagnostic was
|
// Print out any diagnostics, unless it's likely that the diagnostic was
|
||||||
// generated trying to parse a "normal" configuration setting. Exit with an
|
// generated trying to parse a "normal" configuration setting. Exit with an
|
||||||
@@ -115,9 +116,7 @@ fn run_args(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if FIELDS.contains(&d.key.as_str()) {
|
if FIELDS.contains(&d.key.as_str()) {
|
||||||
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&format!(
|
write_diagnostic(stderr, d)?;
|
||||||
"config error: {d}\n"
|
|
||||||
)))?;
|
|
||||||
exit = true;
|
exit = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +132,7 @@ fn run_args(
|
|||||||
return Ok(1);
|
return Ok(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = match Config::load() {
|
let config = match load_config() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
write!(stderr, "Unable to load config: {err}")?;
|
write!(stderr, "Unable to load config: {err}")?;
|
||||||
@@ -150,9 +149,7 @@ fn run_args(
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&format!(
|
write_diagnostic(stderr, d)?;
|
||||||
"config error: {d}\n"
|
|
||||||
)))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let font_grid_set = match SharedGridSet::new() {
|
let font_grid_set = match SharedGridSet::new() {
|
||||||
@@ -227,7 +224,7 @@ fn lookup(
|
|||||||
) -> Result<Option<u8>, Error> {
|
) -> Result<Option<u8>, Error> {
|
||||||
let ch = char::from_u32(cp).unwrap_or('\u{FFFD}');
|
let ch = char::from_u32(cp).unwrap_or('\u{FFFD}');
|
||||||
let Ok(Some(idx)) = grid.get_index(cp, style, presentation) else {
|
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);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,9 +232,12 @@ fn lookup(
|
|||||||
let face = match st.resolver.collection.get_face(idx) {
|
let face = match st.resolver.collection.get_face(idx) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(GetFaceError::Entry(EntryError::SpecialHasNoFace)) => {
|
Err(GetFaceError::Entry(EntryError::SpecialHasNoFace)) => {
|
||||||
writeln!(
|
write_lookup(
|
||||||
stdout,
|
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);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -248,18 +248,100 @@ fn lookup(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let name = face.name();
|
let name = face.name();
|
||||||
writeln!(
|
write_lookup(
|
||||||
stdout,
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#![allow(clippy::unwrap_used)]
|
#![allow(clippy::unwrap_used)]
|
||||||
use super::*;
|
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 {
|
fn parse(args: &[&[u8]]) -> Options {
|
||||||
let mut full: Vec<&[u8]> = vec![b"ghostty"];
|
let mut full: Vec<&[u8]> = vec![b"ghostty"];
|
||||||
full.extend_from_slice(args);
|
full.extend_from_slice(args);
|
||||||
@@ -287,7 +369,7 @@ mod tests {
|
|||||||
let o = parse(&[b"--cp=4294967295"]);
|
let o = parse(&[b"--cp=4294967295"]);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut err = 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();
|
let text = String::from_utf8(err).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
text.starts_with("config error: cli:") && text.contains("cp: invalid value"),
|
text.starts_with("config error: cli:") && text.contains("cp: invalid value"),
|
||||||
@@ -309,7 +391,7 @@ mod tests {
|
|||||||
let mut err = Vec::new();
|
let mut err = Vec::new();
|
||||||
// Without a usable font setup this can fail earlier; only assert the
|
// Without a usable font setup this can fail earlier; only assert the
|
||||||
// Zig message when the grid was available.
|
// 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();
|
let err = String::from_utf8(err).unwrap();
|
||||||
if err.contains("Unable to") && !err.contains("as unicode") {
|
if err.contains("Unable to") && !err.contains("as unicode") {
|
||||||
return;
|
return;
|
||||||
@@ -317,4 +399,47 @@ mod tests {
|
|||||||
assert_eq!(rc, 1);
|
assert_eq!(rc, 1);
|
||||||
assert_eq!(err, "Unable to parse string as unicode: error.InvalidUtf8");
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,9 @@ crossbeam-channel = "0.5.17"
|
|||||||
[features]
|
[features]
|
||||||
# Mirrors Zig's `wasm_shared` build option (SharedArrayBuffer-backed memory).
|
# Mirrors Zig's `wasm_shared` build option (SharedArrayBuffer-backed memory).
|
||||||
wasm-shared = []
|
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
|
# Same as `[workspace.lints]` except `unsafe_code` is denied instead of
|
||||||
# forbidden: the wasm JS import blocks (`src/wasm`) need `unsafe`.
|
# forbidden: the wasm JS import blocks (`src/wasm`) need `unsafe`.
|
||||||
|
|||||||
+132
-28
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
io,
|
io::{self, Read},
|
||||||
process::{Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Kind of URL being opened (`apprt.action.OpenUrl.Kind`). On Linux the
|
/// 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
|
/// apprt for Ghostty, you should consider doing something special-cased
|
||||||
/// for your platform.
|
/// for your platform.
|
||||||
pub fn open(kind: OpenUrlKind, url: &OsStr) -> io::Result<()> {
|
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
|
// Spawn the process on our same thread so we can detect failure
|
||||||
// quickly.
|
// quickly.
|
||||||
let child = cmd.spawn()?;
|
let child = spawn_child(open_command(kind, url)?)?;
|
||||||
|
|
||||||
// Create a thread that handles collecting output and reaping
|
// Create a thread that handles collecting output and reaping
|
||||||
// the process. This is done in a separate thread because SOME
|
// 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(())
|
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
|
/// Build the platform's "open URL" command (Zig `open`'s switch over
|
||||||
/// `builtin.os.tag`).
|
/// `builtin.os.tag`).
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -107,24 +111,58 @@ fn open_command(_kind: OpenUrlKind, _url: &OsStr) -> io::Result<Command> {
|
|||||||
Err(io::Error::new(io::ErrorKind::Unsupported, "unimplemented"))
|
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
|
||||||
// 50 KiB is the default value used by std.process.Child.run and should
|
/// be enough to get the output we care about.
|
||||||
// be enough to get the output we care about.
|
const OUTPUT_MAX_SIZE: usize = 50 * 1024;
|
||||||
const OUTPUT_MAX_SIZE: usize = 50 * 1024;
|
|
||||||
|
|
||||||
let output = match child.wait_with_output() {
|
/// Read at most [`OUTPUT_MAX_SIZE`] bytes; more is an error
|
||||||
Ok(o) => o,
|
/// (`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) => {
|
Err(e) => {
|
||||||
log::warn!("error waiting for open command err={e}");
|
log::warn!("error collecting open command output err={e}");
|
||||||
return;
|
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
|
// 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.
|
// 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() {
|
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);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ when this file was written.
|
|||||||
| 27 | Build data: webgen output | `build/webgen/main_actions.zig:5-6`, `main_commands.zig:6`, `main_config.zig:6` | A 2048-byte stdout buffer that is never flushed, so the generated website files are truncated. | The complete files are emitted. | e4cdd8e | none (checked byte-identical to Zig output of a patched copy, per `final-Z4.md`) |
|
| 27 | Build data: webgen output | `build/webgen/main_actions.zig:5-6`, `main_commands.zig:6`, `main_config.zig:6` | A 2048-byte stdout buffer that is never flushed, so the generated website files are truncated. | The complete files are emitted. | e4cdd8e | none (checked byte-identical to Zig output of a patched copy, per `final-Z4.md`) |
|
||||||
| 28 | CLI: `+list-colors` plain output | `cli/list_colors.zig:59-72` | 4096-byte buffer, never flushed; piped output stops midway through `PeachPuff3` (12,297 bytes). | Flushed (15,434 bytes). | 6286e02 | none; found by RV11 (`review-rv11-codex.md`) |
|
| 28 | CLI: `+list-colors` plain output | `cli/list_colors.zig:59-72` | 4096-byte buffer, never flushed; piped output stops midway through `PeachPuff3` (12,297 bytes). | Flushed (15,434 bytes). | 6286e02 | none; found by RV11 (`review-rv11-codex.md`) |
|
||||||
| 29 | CLI: `+list-themes` diagnostics | `cli/list_themes.zig:124-131,171` | The messages ("Could not find the Ghostty resources directory", "No themes found...") go to a 4096-byte stderr buffer that is never flushed, so they are lost. | Flushed when the action ends. | `410d8fc` fix-deviant(cli) | `zig_writer::tests::unflushed_buffer_is_flushed_on_drop` |
|
| 29 | CLI: `+list-themes` diagnostics | `cli/list_themes.zig:124-131,171` | The messages ("Could not find the Ghostty resources directory", "No themes found...") go to a 4096-byte stderr buffer that is never flushed, so they are lost. | Flushed when the action ends. | `410d8fc` fix-deviant(cli) | `zig_writer::tests::unflushed_buffer_is_flushed_on_drop` |
|
||||||
| 30 | CLI: stdout as a regular file | `std.fs.File.writer` (Zig 0.15.2) as used in `cli/show_face.zig:68-73`, `cli/help.zig:34`, `cli/validate_config.zig:43-46`, `cli/show_config.zig:84` | The writer `pwrite`s at its own offset starting at 0: with stdout redirected to a file, output overwrites itself and earlier file content, and `Writer.end` truncates. | Always appends at the current end; no truncation. | `410d8fc` fix-deviant(cli) (append behaviour first in `f76179f`) | `zig_writer::tests::regular_file_output_is_complete_and_appended` |
|
| 30 | CLI: stdout as a regular file | `std.fs.File.writer` (Zig 0.15.2) as used in `cli/show_face.zig:68-73`, `cli/help.zig:34`, `cli/validate_config.zig:43-46`, `cli/show_config.zig:84` | The writer `pwrite`s at its own offset starting at 0: with stdout redirected to a file, output overwrites itself and earlier file content, and `Writer.end` truncates. | Writes sequentially at the inherited descriptor's current offset (no `pwrite` from offset 0, so earlier content and the writers' own output are not overwritten; this appends only when the descriptor is opened for append, e.g. `>>`); no truncation. | `410d8fc` fix-deviant(cli) (append behaviour first in `f76179f`) | `zig_writer::tests::regular_file_output_is_complete_and_appended` |
|
||||||
|
|
||||||
## Upstream quirks deliberately kept (not fixed)
|
## Upstream quirks deliberately kept (not fixed)
|
||||||
|
|
||||||
@@ -81,22 +81,18 @@ Only entries with an observable behavioural difference are listed.
|
|||||||
| Area | Zig/Ghostty behaviour | Rust behaviour | Where | Commit |
|
| Area | Zig/Ghostty behaviour | Rust behaviour | Where | Commit |
|
||||||
|------|-----------------------|----------------|-------|--------|
|
|------|-----------------------|----------------|-------|--------|
|
||||||
| Swift macOS app | Ships `macos/` | Not ported; the Rust side provides the `libghostty` C API, Metal/CoreText code, XCFramework script. Only compile-checked (`just check-macos`), never run (no macOS here). | `ARCHITECTURE.md` (Out of scope), `notes/macos-M1*.md`, `final-Z4.md` | bab49ee, 75bf604, 231ee35 |
|
| Swift macOS app | Ships `macos/` | Not ported; the Rust side provides the `libghostty` C API, Metal/CoreText code, XCFramework script. Only compile-checked (`just check-macos`), never run (no macOS here). | `ARCHITECTURE.md` (Out of scope), `notes/macos-M1*.md`, `final-Z4.md` | bab49ee, 75bf604, 231ee35 |
|
||||||
| Windows and wasm | Native targets | Compile-checked only (`just check-windows`, `just check-wasm`); not run. | `notes/windows-WIN.md`, `notes/wasm-W1.md` | 76771be, d89691b |
|
| Windows (and wasm natively) | Native targets | Windows is compile-checked only (`just check-windows`), never run. wasm is built and its Node smoke/ABI tests run (`just check-wasm`, `scripts/wasm-check.sh`), but not in a real browser. | `notes/windows-WIN.md`, `notes/wasm-W1.md` | 76771be, d89691b |
|
||||||
| Crash reporting | Sentry with Breakpad on macOS, `SIGSEGV` handlers, local DB/cache, sessions, init thread | In-process reporter: `.ghosttycrash` envelopes in `$XDG_STATE_HOME/ghostty/crash`, panic hook and signal handler, minidumps on macOS/Windows. No Sentry DB/cache dir, no sessions, no init thread; reports are written on any panic; the crash dir is resolved at init; the signal path writes no attachment. | `notes/crash-CR.md`, `ARCHITECTURE.md` (Scope changes) | b3145ad, 72bfac1 |
|
| Crash reporting | Sentry with Breakpad on macOS, `SIGSEGV` handlers, local DB/cache, sessions, init thread | In-process reporter: `.ghosttycrash` envelopes in `$XDG_STATE_HOME/ghostty/crash`, panic hook and signal handler, minidumps on macOS/Windows. No Sentry DB/cache dir, no sessions, no init thread; reports are written on any panic; the crash dir is resolved at init; the signal path writes no attachment. | `notes/crash-CR.md`, `ARCHITECTURE.md` (Scope changes) | b3145ad, 72bfac1 |
|
||||||
| Surface copy-on-select on release | `setSelection` returns early when the new selection equals the old one | Left-release copies directly via `copy_on_select`, because the early return would never copy | `notes/ghostty-core.md` | ad76ae2 |
|
| Surface copy-on-select on release | `setSelection` returns early when the new selection equals the old one | Left-release copies directly via `copy_on_select`, because the early return would never copy | `notes/ghostty-core.md` | ad76ae2 |
|
||||||
| Locking / threading | Renderer mutex held while sending mailbox messages and running mouse reports (`.locked` sends); `process_links` called unlocked | Reports, IO messages and runtime actions are collected and run after the lock is released; `process_links` takes the lock itself; termio overflow goes to an ordered outbox | `notes/ghostty-core.md`, `notes/ghostty-termio-IO*.md` | 6629fb0, ad76ae2 |
|
| Locking / threading | Renderer mutex held while sending mailbox messages and running mouse reports (`.locked` sends); `process_links` called unlocked | Reports, IO messages and runtime actions are collected and run after the lock is released; `process_links` takes the lock itself; termio overflow goes to an ordered outbox | `notes/ghostty-core.md`, `notes/ghostty-termio-IO*.md` | 6629fb0, ad76ae2 |
|
||||||
| Invalid preedit text | Returns an error | Decoded lossily (U+FFFD) for renderer codepoints; no panic | `final-Z10.md` | db95444 (unverified) |
|
|
||||||
| Rendering of overlay / images | z2d overlay, wuffs image decoding, glslang+SPIR-V+spirv-cross | Direct RGBA overlay with approximate hairline placement; `png`/`jpeg-decoder`; `naga` GLSL front end (narrower than glslang, with a sampler2D helper rewrite) | `notes/ghostty-renderer-R1.md`, `R2.md`, `gaps-X2.md`; `just visual-parity` reports 0 differing pixels for the tested scenes | 568ce78, 871c301, f68b11d |
|
| Rendering of overlay / images | z2d overlay, wuffs image decoding, glslang+SPIR-V+spirv-cross | Direct RGBA overlay with approximate hairline placement; `png`/`jpeg-decoder`; `naga` GLSL front end (narrower than glslang, with a sampler2D helper rewrite) | `notes/ghostty-renderer-R1.md`, `R2.md`, `gaps-X2.md`; `just visual-parity` reports 0 differing pixels for the tested scenes | 568ce78, 871c301, f68b11d |
|
||||||
| Oniguruma | Native regex library for links and tmux | Hand-written matchers for the tmux parser; link regexes use the minimal `ghostty-onig` FFI crate | `notes/ghostty-vt-W2.md` | b2b248e, 2aa2a12 |
|
| Oniguruma | Native regex library for links and tmux | Hand-written matchers for the tmux parser; link regexes use the minimal `ghostty-onig` FFI crate | `notes/ghostty-vt-W2.md` | b2b248e, 2aa2a12 |
|
||||||
| zlib / PNG / base64 / shm | zig std, wuffs, simdutf, mmap | `flate2`, `png`, `base64` crate (ASCII whitespace ignored), `rustix` shm reads without mmap | `notes/ghostty-terminal-kitty.md`, `notes/ghostty-vt-W2.md` | d47408f, b2b248e |
|
| zlib / PNG / base64 / shm | zig std, wuffs, simdutf, mmap | `flate2`, `png`, `base64` crate (ASCII whitespace ignored), `rustix` shm reads without mmap | `notes/ghostty-terminal-kitty.md`, `notes/ghostty-vt-W2.md` | d47408f, b2b248e |
|
||||||
| VT parser buffer | Fixed 2048-byte buffer with a reserved NUL | Growing `Vec<u8>` with a cap; differs only at exactly 2048 payload bytes for osc9/iterm2/text_sizing | `notes/ghostty-vt-W1.md` | b2b248e |
|
| VT parser buffer | Fixed 2048-byte buffer with a reserved NUL | Growing `Vec<u8>` with a cap that models the reserved-NUL capacity; no observable difference is known (internal representation only) | `notes/ghostty-vt-W1.md` | b2b248e |
|
||||||
| CSI W logging | Logs a spurious "invalid cursor tabulation control" after `CSI 2 W` / `CSI 5 W` | No log line (log-only) | `notes/ghostty-vt-stream.md` | 47b479d |
|
| CSI W logging | Logs a spurious "invalid cursor tabulation control" after `CSI 2 W` / `CSI 5 W` | No log line (log-only) | `notes/ghostty-vt-stream.md` | 47b479d |
|
||||||
| Searching | `CircBuf` + overlap buffer, libxev thread | `VecDeque`, `crossbeam-channel`, 24 ms refresh timer; identical results, assertions about buffer layout dropped | `notes/ghostty-terminal-search-tmux.md` | 8e9be42 |
|
| Searching | `CircBuf` + overlap buffer, libxev thread | `VecDeque`, `crossbeam-channel`, 24 ms refresh timer; identical results, assertions about buffer layout dropped | `notes/ghostty-terminal-search-tmux.md` | 8e9be42 |
|
||||||
| Config strings | UTF-8 not required in many places | Escaped-bytes representation (`EncodedOs`) so non-UTF-8 survives; behaviour matched to Zig error names, but some filesystem diagnostics still print Rust OS error text (for example `theme=/etc/passwd/x`) | `notes/review-rv11-codex.md` (item 3; later status: unverified) | 672e8a4, 4bbe0d7 |
|
| Config strings | UTF-8 not required in many places | Escaped-bytes representation (`EncodedOs`) so non-UTF-8 survives; behaviour and error names matched to Zig (for example `theme=/etc/passwd/x` gives `error.NotDir`, checked against the oracle) | `notes/review-rv11-codex.md` (item 3) | 672e8a4, 4bbe0d7 |
|
||||||
| `initial_input` escaping | `std.zig.stringEscape` (`\xNN`) | Non-ASCII code points use `\u{..}` because the config `Raw` value is a `String` | `notes/libghostty-L1.md` | 75bf604 |
|
| `+version` / build info | Real build metadata | Placeholder `build_info` values in places; `gtk-opengl-debug` follows `cfg!(debug_assertions)` (the `release` profile has them on, `production` off) | `notes/ghostty-cli.md`, `notes/ghostty-config.md` | 6286e02 |
|
||||||
| `+version` / build info | Real build metadata | Placeholder `build_info` values in places; `Rust version` row empty; `gtk-opengl-debug` follows `cfg!(debug_assertions)` (the `release` profile has them on, `production` off) | `notes/ghostty-cli.md`, `notes/ghostty-config.md` | 6286e02 |
|
|
||||||
| `codepoint-width --mode=wcwidth` (bench) | Calls libc `wcwidth` | Warn and no-op (libc FFI is forbidden); `simd` mode uses the same tables | `notes/perf-P1.md`, `notes/compat-GC-bench.md` | 0c4f0ee |
|
|
||||||
| D-Bus new-window arguments | Plain strings | Internal escape encoding; a literal U+FDD0 followed by a character can decode differently | `notes/review-rv8-codex.md` item 9 (status: unverified) | 1a13e8b |
|
|
||||||
| OSC 52 base64 acceptance | Surface.zig:2149,2163 rules | Was different in both directions (Codex core audit); fixed to match Zig | `notes/review-core-codex.md` | 2f58f02 |
|
| OSC 52 base64 acceptance | Surface.zig:2149,2163 rules | Was different in both directions (Codex core audit); fixed to match Zig | `notes/review-core-codex.md` | 2f58f02 |
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Z18 notes
|
||||||
|
|
||||||
|
## A. Deviant fix (separable)
|
||||||
|
- `crates/ghostty-apprt-gtk/src/ipc/new_window.rs`: `arguments_variant` now validates (UTF-8, no NUL) and returns `Error::IpcFailed` with a stderr message instead of `process::abort()` (Zig `new_window.zig:49-53`). Validation runs before connecting to the bus. Test `invalid_dbus_strings_are_rejected_without_abort` replaces the SIGABRT subprocess test.
|
||||||
|
- `docs/rewrite/DEVIANT_FIXES.md`: row 31.
|
||||||
|
|
||||||
|
## B. Parity
|
||||||
|
1. `crates/ghostty-cli/src/show_face.rs`: `write_diagnostic` / `write_lookup` write Zig's exact fragments (literal, `cli:`, digits, `:`, key, `: `, message, `\n`; hex padding separate from digits). Oracle (pipe) check: n = 0..44 copies of `--font-size=bad` x cp {65, 7, 0x1F600}: 0 diffs (includes the 23x case). Tests: `diagnostics_drain_at_zig_fragment_boundaries`, `lookup_line_pads_hex_and_drains_per_fragment`.
|
||||||
|
2. `crates/ghostty-os/src/open.rs`: stdin inherited; `LD_LIBRARY_PATH` removed only with the new `snap` cargo feature (default off; forwarded by root `Cargo.toml` `snap`); stdout/stderr bounded to 50 KiB each (overflow = error, like `collectOutput`; child is still reaped, unlike Zig). Tests: `opener_inherits_stdin`, `snap_env_is_stripped_only_for_snap_builds`, `output_collection_is_bounded_per_stream`.
|
||||||
|
3. `show_face.rs`: `run_args` takes a config loader; tests use explicit args (`test_config`), `run` uses `Config::load`.
|
||||||
|
|
||||||
|
## C. DEVIANT_FIXES.md corrections
|
||||||
|
Row 30 (offset wording) and the wasm, VT parser buffer, config strings, `+version` rows reworded; removed: invalid preedit, `initial_input` escaping, `wcwidth` bench, D-Bus escape encoding (no longer deviations).
|
||||||
Reference in new issue
Block a user