// SPDX-License-Identifier: GPL-2.0-only // // rs-mrxvt — a modernized, distro-agnostic mrxvt-inspired terminal emulator. // // Copyright (C) 2024 rs-mrxvt contributors // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation; either version 2 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, see . //! PTY session wrapper. //! //! Wraps [`portable_pty`] to give us a clean send/recv pair plus resize. //! The master FD is held as raw so we can poll it from the event loop //! without spawning a thread per tab (the original mrxvt did per-tab threads; //! we use a single poller for the whole app to keep things cache-friendly). use std::cell::RefCell; use std::io::{self, Read, Write}; use anyhow::{Context, Result}; use portable_pty::{CommandBuilder, MasterPty, PtySize}; use crate::config::Profile; /// A live PTY session. /// /// The master is held as a `Box`. If you need shared /// access across threads, wrap the whole `PtySession` in an `Arc>` /// — the per-tab state is small enough that this is cheaper than trying to /// share the master alone. /// /// ## Writer lifetime /// /// The PTY writer is taken **once** at spawn (via `MasterPty::take_writer()`) /// and held for the lifetime of this `PtySession`. This is mandatory: /// `portable_pty` explicitly forbids calling `take_writer()` more than once, /// and dropping the writer sends EOF to the child process (its `Drop` impl /// writes `\n` + VEOF to the PTY). If we took a fresh writer per write — as /// an earlier version did — the first keystroke would kill the child shell /// and every subsequent keystroke would silently fail. pub struct PtySession { pub master: Box, pub pid: u32, pub reader: Box, /// The single PTY writer, held for the session lifetime. `RefCell` /// because `Write::write` needs `&mut self` while the public API /// (`write_input`, `route_input`) threads `&self` through the /// broadcast fan-out. All access is from the main event-loop thread, /// so `RefCell` (not `Mutex`) is the right primitive. writer: RefCell>, } impl PtySession { /// Spawn a new PTY running `profile.command` (or `$SHELL` if unset). pub fn spawn(profile: &Profile, fallback_shell: &str, cols: u16, rows: u16) -> Result { let pty_system = portable_pty::native_pty_system(); let pair = pty_system .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .context("opening pty")?; let cmd = build_command(profile, fallback_shell)?; let child = pair .slave .spawn_command(cmd) .context("spawning child process")?; let pid = child.process_id().unwrap_or(0) as u32; // Take a reader BEFORE dropping the slave so the kernel keeps the // master side alive. let reader = pair .master .try_clone_reader() .context("cloning pty reader")?; // Take the writer ONCE, now, and hold it for the session lifetime. // portable_pty forbids a second `take_writer()` call, and dropping // the writer sends EOF to the child — both of which would break // per-write writer acquisition. let writer = pair .master .take_writer() .context("taking pty writer")?; let master = pair.master; // Drop the slave handle in the parent. The child still has its FDs // (inherited via dup2 inside portable-pty) so it can keep talking. drop(pair.slave); Ok(Self { master, pid, reader, writer: RefCell::new(writer) }) } /// Send raw bytes to the child process (e.g. keystrokes, paste). /// /// Borrows the session-long writer via `RefCell`; all writes go through /// the same `Box` taken at spawn. pub fn write_all(&self, data: &[u8]) -> io::Result<()> { let mut w = self.writer.borrow_mut(); w.write_all(data)?; w.flush() } /// Resize the PTY. Called when the window/tab area changes. pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { self.master .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .context("resizing pty") } /// Returns the PID of the child shell (0 if unknown). pub fn pid(&self) -> u32 { self.pid } /// Take ownership of the reader (used by the per-tab reader thread). pub fn reader_clone(&mut self) -> Result> { self.master .try_clone_reader() .context("cloning PTY reader") } } fn build_command(profile: &Profile, fallback_shell: &str) -> Result { let mut cmd = if profile.command.is_empty() { // Default shell. A login shell (argv0 `-bash`) sources /etc/profile // and ~/.bash_profile, where distros export the GUI session // environment (D-Bus, flatpak/snap paths, XDG dirs). Without it, // GUI apps launched from rs-mrxvt can fail to find their display // environment. This matches the classic mrxvt `loginShell: True` // default. if profile.login_shell { CommandBuilder::new_default_prog() } else { CommandBuilder::new(fallback_shell) } } else { // Explicit command (`-e CMD`, profiles): plain exec, no login shell. let mut cmd = CommandBuilder::new(&profile.command[0]); for arg in &profile.command[1..] { cmd.arg(arg); } cmd }; // Working directory. if let Some(cwd) = &profile.cwd { cmd.cwd(cwd); } // We are the terminal emulator, so we own TERM: advertise the // emulation we actually provide. Set the defaults *before* profile // env overrides so a profile can still override them. cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); // Environment overrides (last, so they win — including over TERM). for (k, v) in &profile.env { cmd.env(k, v); } warn_if_no_graphical_env(&cmd); Ok(cmd) } /// One-time diagnostic when the child environment has no display vars. /// /// The environment is inherited from rs-mrxvt's own process, so if neither /// `DISPLAY` nor `WAYLAND_DISPLAY` is set, rs-mrxvt itself was started from /// a non-graphical context (TTY, stripped service, `sudo`), and every GUI /// app launched from its tabs will refuse to start. Tell the user once, /// instead of leaving them to wonder why `firefox` "refuses to run". fn warn_if_no_graphical_env(cmd: &CommandBuilder) { use std::sync::atomic::{AtomicBool, Ordering}; static WARNED: AtomicBool = AtomicBool::new(false); let has_display = cmd .get_env("DISPLAY") .map(|v| !v.is_empty()) .unwrap_or(false); let has_wayland = cmd .get_env("WAYLAND_DISPLAY") .map(|v| !v.is_empty()) .unwrap_or(false); if !has_display && !has_wayland && !WARNED.swap(true, Ordering::Relaxed) { log::warn!( "neither DISPLAY nor WAYLAND_DISPLAY is set in the child environment; \ GUI apps launched from rs-mrxvt will not start \ (start rs-mrxvt from a graphical session)" ); } } #[cfg(test)] mod tests { use super::*; use crate::config::Profile; use std::collections::HashMap; use std::time::{Duration, Instant}; /// Read from the PTY until `needle` appears or the timeout hits. fn read_until(session: &mut PtySession, needle: &str, timeout: Duration) -> String { let deadline = Instant::now() + timeout; let mut acc = String::new(); let mut buf = [0u8; 512]; while Instant::now() < deadline { match session.reader.read(&mut buf) { Ok(0) => break, Ok(n) => { acc.push_str(&String::from_utf8_lossy(&buf[..n])); if acc.contains(needle) { break; } } Err(_) => break, } } acc } #[test] fn build_command_uses_profile_command() { let p = Profile { command: vec!["echo".into(), "hi".into()], cwd: None, tag: None, env: HashMap::new(), login_shell: false, }; let _cmd = build_command(&p, "/bin/sh").unwrap(); } #[test] fn build_command_falls_back_to_shell() { let p = Profile::default(); let _cmd = build_command(&p, "/bin/sh").unwrap(); } #[test] fn default_shell_runs_as_login_shell() { // Force sh as $SHELL so the login-shell path is deterministic // (portable-pty resolves $SHELL from the builder env). The PTY echo // shows the literal `MARK-$0`; the shell's own output expands it, // so "MARK--" can only come from a login shell's argv0 ("‑sh"). let mut env = HashMap::new(); env.insert("SHELL".to_string(), "/bin/sh".to_string()); let p = Profile { command: vec![], cwd: None, tag: None, env, login_shell: true, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); session.write_all(b"echo MARK-$0; exit\n").unwrap(); let out = read_until(&mut session, "MARK--", Duration::from_secs(5)); assert!( out.contains("MARK--"), "login shell should report $0 starting with '-', got: {out:?}" ); } #[test] fn login_shell_can_be_disabled() { let mut env = HashMap::new(); env.insert("SHELL".to_string(), "/bin/sh".to_string()); let p = Profile { command: vec![], cwd: None, tag: None, env, login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); session.write_all(b"echo MARK-$0; exit\n").unwrap(); // Non-login: $0 is the plain path ("/bin/sh"), never dash-prefixed. let out = read_until(&mut session, "MARK-/", Duration::from_secs(5)); assert!( out.contains("MARK-/") && !out.contains("MARK--"), "non-login shell should have a plain $0, got: {out:?}" ); } #[test] fn child_env_matches_parent_for_display_vars() { // Whatever display vars the parent has must reach the child — // this is the invariant GUI apps depend on. let p = Profile { command: vec![ "sh".into(), "-c".into(), "printf 'D=%s|W=%s' \"${DISPLAY:-none}\" \"${WAYLAND_DISPLAY:-none}\"".into(), ], cwd: None, tag: None, env: HashMap::new(), login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); let out = read_until(&mut session, "|W=", Duration::from_secs(5)); let want_display = std::env::var("DISPLAY").unwrap_or_else(|_| "none".into()); let want_wayland = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "none".into()); assert!( out.contains(&format!("D={want_display}|W={want_wayland}")), "child display env must match parent, got: {out:?}" ); } #[test] fn profile_env_overrides_term() { let mut env = HashMap::new(); env.insert("TERM".to_string(), "myterm-9".to_string()); let p = Profile { command: vec!["sh".into(), "-c".into(), "printf 'T=%s' \"$TERM\"".into()], cwd: None, tag: None, env, login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); let out = read_until(&mut session, "T=", Duration::from_secs(5)); assert!( out.contains("T=myterm-9"), "profile env must override the built-in TERM, got: {out:?}" ); } #[test] fn spawn_echo_and_read_output() { // Spawns `sh -c 'echo hello; exit 0'` and reads "hello\n". let p = Profile { command: vec!["sh".into(), "-c".into(), "echo hello".into()], cwd: None, tag: None, env: HashMap::new(), login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); let mut buf = [0u8; 64]; let n = session.reader.read(&mut buf).unwrap(); let out = String::from_utf8_lossy(&buf[..n]); assert!(out.contains("hello"), "got: {out:?}"); } /// Regression test for the "first keystroke kills the shell" bug. /// /// The old `write_all` called `take_writer()` on every write. portable_pty /// forbids that ("cannot take writer more than once") AND its writer's /// `Drop` sends EOF to the child. So the first write succeeded but killed /// the shell, and every subsequent write failed silently. /// /// This test writes multiple times through the same `PtySession` and /// verifies that (a) every write returns Ok and (b) the child `cat` /// process is still alive (we can read back the echo of the second write). #[test] fn multiple_writes_do_not_kill_child() { let p = Profile { command: vec!["cat".into()], cwd: None, tag: None, env: HashMap::new(), login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); // First write must succeed. session.write_all(b"first\n").expect("first write should succeed"); // Give `cat` a moment to echo it back so we can drain it. std::thread::sleep(std::time::Duration::from_millis(30)); let mut drain = [0u8; 128]; let _ = session.reader.read(&mut drain); // Second write must ALSO succeed — this is the regression. // The old code failed here with "cannot take writer more than once". session.write_all(b"second\n").expect("second write should succeed"); // Read back the echo of the second write. If the first write's // writer-drop had sent EOF, `cat` would have exited and we'd see // EOF (0 bytes) instead of "second". std::thread::sleep(std::time::Duration::from_millis(30)); let mut buf = [0u8; 128]; let n = session.reader.read(&mut buf).expect("read should not error"); let out = String::from_utf8_lossy(&buf[..n]); assert!( out.contains("second"), "child should have echoed 'second' back, got {out:?} ({} bytes)", n ); } /// The writer is taken exactly once at spawn; calling write_all many /// times must never error or panic. #[test] fn many_sequential_writes_all_succeed() { let p = Profile { command: vec!["cat".into()], cwd: None, tag: None, env: HashMap::new(), login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); for i in 0..50 { session.write_all(format!("line {i}\n").as_bytes()) .expect("every write should succeed"); // Drain the echo so the PTY buffer doesn't fill and block. std::thread::sleep(std::time::Duration::from_millis(5)); let mut drain = [0u8; 256]; let _ = session.reader.read(&mut drain); } } }