feat(termio): port pty, child command spawning, shell integration

Ports the parts of Ghostty's IO layer that don't depend on the terminal:
- pty.zig: pty pair, window size, mode, child-side setsid/TIOCSCTTY;
  master is CLOEXEC with IUTF8 set
- Command.zig: fork/execvpe with Ghostty's exact child setup (stdio
  dup2, best-effort chdir, rlimit restore, os/runtime pre-exec hooks,
  cgroup handling) and exit status decoding
- shell_integration.zig for bash/zsh/fish/elvish/nushell plus feature
  flags; scripts copied verbatim to resources/shell-integration/
- terminfo generator for xterm-ghostty, byte-identical to Zig's encode
  output (golden file produced with the real Zig code)

This crate opts into unsafe_code = "deny" (fork/exec need it) with
allow + SAFETY comments on minimal items.

Integration left: config-derived option types are local stand-ins;
cgroup D-Bus scope creation is a closure hook; Exec/Termio/Thread/
stream_handler wait for ghostty-terminal. Pty now closes both fds on
drop (Zig never auto-closed the slave) - to re-check when wiring Exec.

Test Plan:
- cargo test -p ghostty-termio (52 tests, incl. real pty spawn test
  expecting "hi\r\n31 97\r\n" from `echo hi; stty size`)
This commit is contained in:
ddidderr committed 2026-10-11 22:06:51 +02:00
1 parent 6792799fbe
commit 59bc84fde0
21 files changed
+7120 -28

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
//! Integration test: run a real child attached to a pty.
#![allow(clippy::unwrap_used)]
use std::{
fs::File,
io::Read,
os::fd::{AsRawFd, OwnedFd},
};
use ghostty_termio::{Command, Pty, Winsize, command::Exit, pty::child_pre_exec};
fn dup(fd: &OwnedFd) -> OwnedFd {
fd.try_clone().unwrap()
}
/// Read from the master until the child side hangs up (EIO on Linux).
fn read_until_hangup(master: OwnedFd) -> String {
let mut f = File::from(master);
let mut out = Vec::new();
let mut buf = [0u8; 4096];
loop {
match f.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => out.extend_from_slice(&buf[..n]),
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn run_on_pty(script: &str, size: Winsize) -> (String, Exit) {
let mut pty = Pty::open(size).unwrap();
let slave = pty.slave.take().unwrap();
let (master_raw, _) = pty.raw_fds();
let slave_raw = slave.as_raw_fd();
let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-c", script]);
cmd.stdin = Some(dup(&slave));
cmd.stdout = Some(dup(&slave));
cmd.stderr = Some(dup(&slave));
cmd.os_pre_exec = Some(Box::new(move || {
// Make the slave our controlling terminal in a new session; this
// also closes the master/slave descriptors in the child.
match child_pre_exec(master_raw, slave_raw) {
Ok(()) => None,
Err(_) => Some(1),
}
}));
cmd.start().unwrap();
// Close our copies of the slave so we see a hangup when the child exits.
drop(cmd.stdin.take());
drop(cmd.stdout.take());
drop(cmd.stderr.take());
drop(slave);
let exit = cmd.wait(true).unwrap();
let out = read_until_hangup(pty.master);
(out, exit)
}
#[test]
fn echo_and_stty_size_on_pty() {
let size = Winsize {
ws_row: 31,
ws_col: 97,
ws_xpixel: 0,
ws_ypixel: 0,
};
let (out, exit) = run_on_pty("echo hi; stty size", size);
assert_eq!(exit, Exit::Exited(0));
// A pty translates \n to \r\n.
assert_eq!(out, "hi\r\n31 97\r\n");
}
#[test]
fn child_has_controlling_terminal() {
// `tty` succeeds only with a terminal on stdin; /dev/tty only opens when
// we have a controlling terminal (TIOCSCTTY).
let (out, exit) = run_on_pty(
"tty >/dev/null && echo tty; echo x > /dev/tty && echo ctty",
Winsize::default(),
);
assert_eq!(exit, Exit::Exited(0));
assert!(out.contains("tty\r\n"), "{out:?}");
assert!(out.contains("ctty\r\n"), "{out:?}");
}
#[test]
fn resize_is_visible_to_child() {
let size = Winsize {
ws_row: 10,
ws_col: 20,
ws_xpixel: 0,
ws_ypixel: 0,
};
let (out, _) = run_on_pty("stty size", size);
assert_eq!(out, "10 20\r\n");
}