169 lines
5.8 KiB
Rust
169 lines
5.8 KiB
Rust
//! 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<dyn portable_pty::Child + Send + Sync>,
|
|
writer: Box<dyn Write + Send>,
|
|
_reader_thread: thread::JoinHandle<()>,
|
|
output: Arc<Mutex<Vec<u8>>>,
|
|
master: Box<dyn portable_pty::MasterPty + Send>,
|
|
}
|
|
|
|
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<u8> {
|
|
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<portable_pty::ExitStatus> {
|
|
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<u32> {
|
|
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,
|
|
}
|
|
}
|