diff --git a/AGENTS.md b/AGENTS.md index d74df63..53a5d66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index e803c37..ea79dd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 4cbbb89..fbeee96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"))'] } diff --git a/src/lib.rs b/src/lib.rs index 98d32f0..3679eea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ pub mod settings; pub mod term; +#[cfg(test)] +mod testlog; pub mod themes; diff --git a/src/term/mod.rs b/src/term/mod.rs index 546e882..79e6604 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -1,4 +1,6 @@ mod pty; mod tap; +#[cfg(unix)] +mod unix; pub use pty::{Launch, Listener, Pty, PtyEvent, fit, spawn}; diff --git a/src/term/pty.rs b/src/term/pty.rs index 419fda7..b26787a 100644 --- a/src/term/pty.rs +++ b/src/term/pty.rs @@ -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 { - 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, size: WindowSize) -> std::io::Result { + super::unix::spawn(launch.program.as_ref(), &launch.cwd, &env, size) +} + +#[cfg(windows)] +fn start(launch: &Launch, env: HashMap, size: WindowSize) -> std::io::Result { + 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>) { @@ -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 { - Ok(pty.child().id()) +fn child_id(pty: &Raw) -> anyhow::Result { + Ok(pty.id()) } #[cfg(windows)] -fn child_id(pty: &tty::Pty) -> anyhow::Result { +fn child_id(pty: &Raw) -> anyhow::Result { 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] diff --git a/src/term/unix.rs b/src/term/unix.rs new file mode 100644 index 0000000..554ddfc --- /dev/null +++ b/src/term/unix.rs @@ -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)>, + cwd: &Path, + env: &HashMap, + size: WindowSize, +) -> io::Result { + 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 { + let entry = || -> io::Result<(String, String, String)> { + let mut buffer = vec![0 as libc::c_char; 4096]; + let mut entry = MaybeUninit::::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, + 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, + 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) -> 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 { + 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()), + } + } +} diff --git a/src/testlog.rs b/src/testlog.rs new file mode 100644 index 0000000..2f8a726 --- /dev/null +++ b/src/testlog.rs @@ -0,0 +1,36 @@ +use std::sync::{Mutex, Once}; + +static LINES: Mutex> = 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) +}