//! Real-PTY pmacs spawner shared across integration tests. //! //! `PmacsPty` owns a real PTY pair plus a pmacs child running //! inside it; a background reader thread drains the master so //! pmacs's writes never block on a full terminal output buffer. //! `spawn_pmacs_in_pty` is the constructor. //! //! First consumer: M5.8 session-reconnect tests //! (`tests/m5_8_acceptance.rs`). Second consumer: M10.11 //! doubled-PTY two-laptop tests (`tests/m10_11_acceptance.rs`). use std::ffi::OsString; use std::io::{Read, Write}; use std::path::Path; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use portable_pty::{CommandBuilder, PtySize}; /// Pmacs spawned inside a real PTY. Holds the master so the slave /// stays alive; `child` is reapable via `try_wait`. The writer /// allows the test to inject keystrokes (e.g. `\x03` for Ctrl-C); /// the reader is kept around so the slave's output buffer doesn't /// fill and block pmacs's writes. pub struct PmacsPty { child: Box, writer: Box, _reader_thread: thread::JoinHandle<()>, output: Arc>>, master: Box, } impl PmacsPty { /// Inject bytes into pmacs's stdin via the PTY master. pub fn write_input(&mut self, bytes: &[u8]) -> std::io::Result<()> { self.writer.write_all(bytes)?; self.writer.flush() } /// Resize the real host PTY in terminal cells. pub fn resize(&self, rows: u16, cols: u16) -> Result<(), String> { self.master .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .map_err(|error| error.to_string()) } /// Snapshot all bytes emitted by pmacs to its host terminal. #[must_use] pub fn output(&self) -> Vec { self.output .lock() .expect("PTY output mutex poisoned") .clone() } /// Poll-wait for pmacs to exit, up to `timeout`. Returns the /// exit status on success, `None` on timeout (and leaves the /// child running for the caller to clean up). pub fn wait_for_exit(&mut self, timeout: Duration) -> Option { let deadline = Instant::now() + timeout; loop { match self.child.try_wait() { Ok(Some(status)) => return Some(status), Ok(None) => {} Err(_) => return None, } if Instant::now() >= deadline { return None; } thread::sleep(Duration::from_millis(20)); } } /// The OS process id of the running pmacs child, if portable-pty /// can surface it. Used by M10.11's Drop-discipline test to /// verify post-drop process death via `kill(pid, 0)`. pub fn process_id(&self) -> Option { self.child.process_id() } } impl Drop for PmacsPty { fn drop(&mut self) { // Best-effort cleanup if the test panicked / timed out. let _ = self.child.kill(); let _ = self.child.wait(); } } /// Spawn pmacs inside a fresh PTY pair, returning a handle that /// owns the master + child + reader-thread. The reader thread /// drains the master so pmacs's writes never block on a full /// terminal buffer. pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols: u16) -> PmacsPty { let pty_system = portable_pty::native_pty_system(); let pair = pty_system .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .expect("openpty"); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_pmacs")); for arg in args { cmd.arg(arg); } // Backfill any of the five storage variables the caller did not set // (`docs/test-ambient-config-isolation-framing.md` ยง1.6a). Callers // pin the roots their assertions read (a per-test `XDG_CONFIG_HOME`, // a `PMACS_STATE_HOME` they inspect); the rest would otherwise be // inherited, and the child would materialize bundled packages into // the developer's real data root. Caller entries win: this only // fills holes. let fallback = pmacs::bootstrap::BootstrapRoots::isolated_under(&super::iso::base()); for (key, value) in fallback.child_env() { if envs.iter().any(|(k, _)| *k == key) { continue; } std::fs::create_dir_all(&value).expect("create isolated root for PTY child"); cmd.env(key, value.as_os_str()); } for (k, v) in envs { let mut value = OsString::new(); value.push(v); cmd.env(k, value); } let child = pair.slave.spawn_command(cmd).expect("spawn pmacs"); // Drop the slave on our side so EOF on the master is detectable // when the child exits (pmacs's stdio is the only thing keeping // the slave alive after this). drop(pair.slave); let writer = pair.master.take_writer().expect("take_writer"); let mut reader = pair.master.try_clone_reader().expect("try_clone_reader"); let output = Arc::new(Mutex::new(Vec::new())); let captured = output.clone(); // Drain and retain reader bytes so terminal-mode restoration can be // asserted without ever exposing pmacs to the test runner's own TTY. let reader_thread = thread::spawn(move || { let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => return, Ok(read) => captured .lock() .expect("PTY output mutex poisoned") .extend_from_slice(&buf[..read]), } } }); PmacsPty { child, writer, _reader_thread: reader_thread, output, master: pair.master, } }