T04: own Unix pty so a failed chdir fails the start; closing logs and reaps
CI / platforms (windows-latest) (push) Failing after 7s
CI / macos (push) Successful in 10m0s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 12:08:18 -04:00
1 parent 96ae85b72c
commit fff436c8e9
8 files changed
+423 -33

No files matched your search

+2 -1
View File
@@ -19,7 +19,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them.
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY.
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
## Rules
@@ -37,4 +37,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-14: the working directory comes from OSC 7, read by a tap on the pty reader, sent by injected shell integration. vte drops OSC 7.
- 2026-10-14: Git through the `git` command line, not libgit2.
- 2026-10-14: license MIT, as Ely.
- 2026-10-14: on Unix the pty is ELYTerminal's own: alacritty 0.26 ignores `chdir`'s result, so a session could start in the wrong folder. Its traits feed alacritty's event loop, keyed 0 and 1 as alacritty's private constants are; alacritty is pinned to =0.26.0 for that.
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
Generated
+3 -1
View File
@@ -1757,12 +1757,14 @@ dependencies = [
"gethostname",
"gpui-pre",
"gpui-pre-platform",
"libc",
"log",
"polling",
"rustix",
"rustix-openpty",
"serde",
"serde_json",
"serde_path_to_error",
"signal-hook",
]
[[package]]
+4 -2
View File
@@ -9,7 +9,7 @@ repository = "https://git.zacharyzhang.com/ZacharyZhang-NY/ELYTerminal"
publish = false
[dependencies]
alacritty_terminal = { version = "0.26", default-features = false }
alacritty_terminal = { version = "=0.26.0", default-features = false }
anyhow = "1"
dirs = "7"
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" }
@@ -25,7 +25,9 @@ serde_json = "1"
serde_path_to_error = "0.1.20"
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "fs"] }
libc = "0.2"
rustix-openpty = "0.2"
signal-hook = "0.4"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
+2
View File
@@ -1,3 +1,5 @@
pub mod settings;
pub mod term;
#[cfg(test)]
mod testlog;
pub mod themes;
+2
View File
@@ -1,4 +1,6 @@
mod pty;
mod tap;
#[cfg(unix)]
mod unix;
pub use pty::{Launch, Listener, Pty, PtyEvent, fit, spawn};
+82 -29
View File
@@ -1,11 +1,12 @@
use std::{borrow::Cow, collections::HashMap, path::PathBuf, sync::Arc};
#[cfg(windows)]
use alacritty_terminal::tty;
use alacritty_terminal::{
event::{Event, EventListener, Notify, OnResize, WindowSize},
event_loop::{EventLoop, Msg, Notifier},
sync::FairMutex,
term::{Config, Term, test::TermSize},
tty::{self, Options, Shell},
};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
@@ -53,6 +54,11 @@ pub struct Pty {
notifier: Notifier,
}
#[cfg(unix)]
type Raw = super::unix::Pty;
#[cfg(windows)]
type Raw = tty::Pty;
/// Starts `launch` on a pty of `columns` by `lines` cells, each `cell` pixels, keeping `scrollback` lines.
pub fn spawn(
launch: &Launch,
@@ -60,14 +66,6 @@ pub fn spawn(
cell: (u16, u16),
scrollback: usize,
) -> anyhow::Result<Pty> {
anyhow::ensure!(
launch.cwd.is_dir(),
"pty: cannot start in {}: no such folder",
launch.cwd.display()
);
#[cfg(unix)]
rustix::fs::access(&launch.cwd, rustix::fs::Access::EXEC_OK)
.map_err(|error| anyhow::anyhow!("pty: cannot enter {}: {error}", launch.cwd.display()))?;
let mut env = HashMap::from([
("TERM".to_string(), "xterm-256color".to_string()),
("COLORTERM".to_string(), "truecolor".to_string()),
@@ -76,20 +74,15 @@ pub fn spawn(
env.insert("LANG".into(), "en_US.UTF-8".into());
}
env.extend(launch.env.iter().cloned());
let options = Options {
shell: launch
.program
.clone()
.map(|(program, args)| Shell::new(program, args)),
working_directory: Some(launch.cwd.clone()),
drain_on_exit: true,
env,
#[cfg(windows)]
escape_args: true,
};
let size = fit(columns, lines, cell);
let pty = tty::new(&options, size, 0)?;
let pid = child_id(&pty)?;
let raw = start(launch, env, size).map_err(|error| {
anyhow::anyhow!(
"pty: cannot start {:?} in {}: {error}",
launch.program,
launch.cwd.display()
)
})?;
let pid = child_id(&raw)?;
log::info!(
"pty {pid}: started {:?} in {}",
launch.program,
@@ -110,7 +103,7 @@ pub fn spawn(
let event_loop = EventLoop::new(
term.clone(),
listener,
Tapped::new(pty, sender, pid),
Tapped::new(raw, sender, pid),
true,
false,
)?;
@@ -124,6 +117,26 @@ pub fn spawn(
})
}
#[cfg(unix)]
fn start(launch: &Launch, env: HashMap<String, String>, size: WindowSize) -> std::io::Result<Raw> {
super::unix::spawn(launch.program.as_ref(), &launch.cwd, &env, size)
}
#[cfg(windows)]
fn start(launch: &Launch, env: HashMap<String, String>, size: WindowSize) -> std::io::Result<Raw> {
let options = tty::Options {
shell: launch
.program
.clone()
.map(|(program, args)| tty::Shell::new(program, args)),
working_directory: Some(launch.cwd.clone()),
drain_on_exit: true,
env,
escape_args: true,
};
tty::new(&options, size, 0)
}
impl Pty {
/// Sends bytes to the program, as typing does.
pub fn write(&self, bytes: impl Into<Cow<'static, [u8]>>) {
@@ -144,17 +157,19 @@ impl Drop for Pty {
fn drop(&mut self) {
if self.notifier.0.send(Msg::Shutdown).is_err() {
log::debug!("pty {}: the event loop had already stopped", self.pid);
} else {
log::info!("pty {}: closing", self.pid);
}
}
}
#[cfg(unix)]
fn child_id(pty: &tty::Pty) -> anyhow::Result<u32> {
Ok(pty.child().id())
fn child_id(pty: &Raw) -> anyhow::Result<u32> {
Ok(pty.id())
}
#[cfg(windows)]
fn child_id(pty: &tty::Pty) -> anyhow::Result<u32> {
fn child_id(pty: &Raw) -> anyhow::Result<u32> {
pty.child_watcher()
.pid()
.map(u32::from)
@@ -198,8 +213,7 @@ mod tests {
}
fn run(script: &str, size: (usize, usize)) -> Ran {
// ponytail: a second init fails by design
env_logger::builder().is_test(true).try_init().ok();
crate::testlog::init();
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
cwd: std::env::temp_dir(),
@@ -255,6 +269,43 @@ mod tests {
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
}
#[test]
fn closing_a_session_hangs_up_its_program() {
crate::testlog::init();
let launch = Launch {
program: Some((
"/bin/sh".into(),
vec!["-c".into(), "printf READY; exec cat".into()],
)),
cwd: std::env::temp_dir(),
env: Vec::new(),
};
let mut pty = spawn(&launch, (20, 4), (8, 16), 100).expect("sh starts");
futures::executor::block_on(async {
while let Some(event) = pty.events.next().await {
let term = pty.term.lock();
let text: String = (0..5)
.map(|column| term.grid()[Line(0)][Column(column)].c)
.collect();
drop(term);
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
break;
}
}
});
let pid = pty.pid;
drop(pty);
let hung_up = format!("pty {pid}: hung up, signal: 1 (SIGHUP)");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !crate::testlog::logged(&hung_up) {
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
assert!(!alive, "the program was reaped");
}
#[test]
fn a_missing_folder_stops_the_start() {
let launch = Launch {
@@ -283,7 +334,9 @@ mod tests {
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
std::fs::remove_dir(&folder).expect("the temp folder goes");
let error = started.err().expect("a folder it cannot enter is an error");
assert!(error.to_string().contains("cannot enter"), "{error}");
let error = error.to_string();
assert!(error.contains("Permission denied"), "{error}");
assert!(error.contains(&folder.display().to_string()), "{error}");
}
#[test]
+292
View File
@@ -0,0 +1,292 @@
use std::{
collections::HashMap,
ffi::{CStr, CString},
fs::File,
io::{self, ErrorKind, Read},
mem::MaybeUninit,
os::{
fd::AsRawFd,
unix::{ffi::OsStrExt, net::UnixStream, process::CommandExt},
},
path::Path,
process::{Child, Command},
sync::Arc,
};
use alacritty_terminal::{
event::{OnResize, WindowSize},
tty::{ChildEvent, EventedPty, EventedReadWrite, ToWinsize},
};
use polling::{Event, PollMode, Poller};
use rustix_openpty::{
openpty,
rustix::termios::{self, InputModes, OptionalActions},
};
use signal_hook::{
SigId,
consts::SIGCHLD,
low_level::{pipe, unregister},
};
/// The keys alacritty's event loop reads pty readiness by.
const READ_WRITE: usize = 0;
const CHILD: usize = 1;
/// A program on a pseudo-terminal, started here so a failed `chdir` fails the start.
pub(crate) struct Pty {
child: Child,
file: File,
signals: UnixStream,
sig: SigId,
}
impl Pty {
pub fn id(&self) -> u32 {
self.child.id()
}
}
/// Starts `program`, or the login shell, in `cwd` on a new pty of `size`.
pub(crate) fn spawn(
program: Option<&(String, Vec<String>)>,
cwd: &Path,
env: &HashMap<String, String>,
size: WindowSize,
) -> io::Result<Pty> {
let pair = openpty(None, Some(&size.to_winsize()))?;
let (master, slave) = (pair.controller, pair.user);
let mut modes = termios::tcgetattr(&master)?;
modes.input_modes.set(InputModes::IUTF8, true);
termios::tcsetattr(&master, OptionalActions::Now, &modes)?;
let user = User::find()?;
let mut command = match program {
Some((program, args)) => {
let mut command = Command::new(program);
command.args(args);
command
}
None => login_shell(&user),
};
command.stdin(slave.try_clone()?);
command.stderr(slave.try_clone()?);
command.stdout(slave.try_clone()?);
command.env("USER", &user.name).env("HOME", &user.home);
command.envs(env);
command.env_remove("XDG_ACTIVATION_TOKEN");
command.env_remove("DESKTOP_STARTUP_ID");
let folder = CString::new(cwd.as_os_str().as_bytes())?;
let (master_fd, slave_fd) = (master.as_raw_fd(), slave.as_raw_fd());
// SAFETY: the hook calls only async-signal-safe libc functions.
unsafe {
command.pre_exec(move || {
if libc::setsid() == -1 || libc::chdir(folder.as_ptr()) == -1 {
return Err(io::Error::last_os_error());
}
if libc::ioctl(slave_fd, libc::TIOCSCTTY as _, 0) == -1 {
return Err(io::Error::last_os_error());
}
libc::close(slave_fd);
libc::close(master_fd);
for signal in [
libc::SIGCHLD,
libc::SIGHUP,
libc::SIGINT,
libc::SIGQUIT,
libc::SIGTERM,
libc::SIGALRM,
] {
libc::signal(signal, libc::SIG_DFL);
}
Ok(())
});
}
let (sender, signals) = UnixStream::pair()?;
let sig = pipe::register(SIGCHLD, sender)?;
signals.set_nonblocking(true)?;
let child = match command.spawn() {
Ok(child) => child,
Err(error) => {
unregister(sig);
return Err(error);
}
};
drop(slave);
let flags = unsafe { libc::fcntl(master_fd, libc::F_GETFL, 0) };
if flags == -1
|| unsafe { libc::fcntl(master_fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1
{
return Err(io::Error::last_os_error());
}
Ok(Pty {
child,
file: File::from(master),
signals,
sig,
})
}
/// macOS runs the shell through `login` so it is a login shell on a tty, as Terminal does.
#[cfg(target_os = "macos")]
fn login_shell(user: &User) -> Command {
let name = user.shell.rsplit('/').next().unwrap_or(&user.shell);
let hush = Path::new(&user.home).join(".hushlogin").exists();
let mut command = Command::new("/usr/bin/login");
command.args([
if hush { "-qflp" } else { "-flp" },
&user.name,
"/bin/zsh",
"-fc",
&format!("exec -a -{name} {}", user.shell),
]);
command
}
#[cfg(not(target_os = "macos"))]
fn login_shell(user: &User) -> Command {
Command::new(&user.shell)
}
struct User {
name: String,
home: String,
shell: String,
}
impl User {
/// USER, HOME and SHELL, each from the password entry when the variable is unset.
fn find() -> io::Result<Self> {
let entry = || -> io::Result<(String, String, String)> {
let mut buffer = vec![0 as libc::c_char; 4096];
let mut entry = MaybeUninit::<libc::passwd>::uninit();
let mut found = std::ptr::null_mut();
let status = unsafe {
libc::getpwuid_r(
libc::getuid(),
entry.as_mut_ptr(),
buffer.as_mut_ptr(),
buffer.len(),
&mut found,
)
};
if status != 0 || found.is_null() {
return Err(io::Error::other("pty: no password entry for this user"));
}
let entry = unsafe { entry.assume_init() };
let text = |field: *const libc::c_char| {
unsafe { CStr::from_ptr(field) }
.to_string_lossy()
.into_owned()
};
Ok((
text(entry.pw_name),
text(entry.pw_dir),
text(entry.pw_shell),
))
};
let (name, home, shell) = match (
std::env::var("USER"),
std::env::var("HOME"),
std::env::var("SHELL"),
) {
(Ok(name), Ok(home), Ok(shell)) => (name, home, shell),
(name, home, shell) => {
let (pw_name, pw_home, pw_shell) = entry()?;
(
name.unwrap_or(pw_name),
home.unwrap_or(pw_home),
shell.unwrap_or(pw_shell),
)
}
};
Ok(Self { name, home, shell })
}
}
impl EventedReadWrite for Pty {
type Reader = File;
type Writer = File;
unsafe fn register(
&mut self,
poll: &Arc<Poller>,
mut interest: Event,
mode: PollMode,
) -> io::Result<()> {
interest.key = READ_WRITE;
unsafe {
poll.add_with_mode(&self.file, interest, mode)?;
poll.add_with_mode(&self.signals, Event::readable(CHILD), PollMode::Level)
}
}
fn reregister(
&mut self,
poll: &Arc<Poller>,
mut interest: Event,
mode: PollMode,
) -> io::Result<()> {
interest.key = READ_WRITE;
poll.modify_with_mode(&self.file, interest, mode)?;
poll.modify_with_mode(&self.signals, Event::readable(CHILD), PollMode::Level)
}
fn deregister(&mut self, poll: &Arc<Poller>) -> io::Result<()> {
poll.delete(&self.file)?;
poll.delete(&self.signals)
}
fn reader(&mut self) -> &mut File {
&mut self.file
}
fn writer(&mut self) -> &mut File {
&mut self.file
}
}
impl EventedPty for Pty {
fn next_child_event(&mut self) -> Option<ChildEvent> {
let mut byte = [0u8; 1];
if let Err(error) = self.signals.read(&mut byte) {
if error.kind() != ErrorKind::WouldBlock {
log::error!("pty {}: the SIGCHLD pipe failed: {error}", self.id());
}
return None;
}
match self.child.try_wait() {
Ok(None) => None,
Ok(status) => Some(ChildEvent::Exited(status)),
Err(error) => {
log::error!(
"pty {}: cannot read the program's state: {error}",
self.id()
);
None
}
}
}
}
impl OnResize for Pty {
fn on_resize(&mut self, size: WindowSize) {
let winsize = size.to_winsize();
if unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCSWINSZ, &winsize) } == -1 {
log::error!(
"pty {}: resize failed: {}",
self.id(),
io::Error::last_os_error()
);
}
}
}
impl Drop for Pty {
fn drop(&mut self) {
unsafe { libc::kill(self.child.id() as libc::pid_t, libc::SIGHUP) };
unregister(self.sig);
match self.child.wait() {
Ok(status) => log::info!("pty {}: hung up, {status}", self.id()),
Err(error) => log::error!("pty {}: cannot reap the program: {error}", self.id()),
}
}
}
+36
View File
@@ -0,0 +1,36 @@
use std::sync::{Mutex, Once};
static LINES: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// Keeps every log line so tests can assert what was logged.
struct Capture;
impl log::Log for Capture {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= log::Level::Debug
}
fn log(&self, record: &log::Record) {
let line = record.args().to_string();
eprintln!("{} {line}", record.level());
LINES.lock().expect("the log lines").push(line);
}
fn flush(&self) {}
}
pub fn init() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
log::set_logger(&Capture).expect("the only logger in tests");
log::set_max_level(log::LevelFilter::Debug);
});
}
pub fn logged(line: &str) -> bool {
LINES
.lock()
.expect("the log lines")
.iter()
.any(|kept| kept == line)
}