Files
Maximilian Roos d4de46b5db refactor: simplify internals and shell configuration (#3853)
This removes duplicated internal paths while making shell configuration
changes safer across the confirmation prompt.

- consolidate config-state clearing and reporting
- inline one-use PR/ref adapters and remove test-only production APIs
- make shell rc and Fish completion writes atomic or preimage-checked,
so concurrent edits are preserved
- keep recognizing historical shell-integration lines while applying
only the cleanup the user previewed
- retarget tests to the canonical APIs and add PTY coverage for
confirmation-window changes

The protected CLI and configuration interfaces are unchanged. Production
code remains substantially smaller; the added test coverage carries the
shell-safety and shared-cache proofs explicitly.

Tested with `cargo run -- hook pre-merge --yes`: 4,659 tests passed,
with Clippy, docs, doctests, snapshots, formatting, and repository
policy checks also clean.

> _This was written by Codex on behalf of max-sixty_
2026-08-19 19:50:43 -07:00

521 lines
20 KiB
Rust

//! PTY execution helpers for integration tests.
//!
//! Compose `build_pty_command` with a runner:
//!
//! - **`build_pty_command`** — builds a `CommandBuilder` with env isolation
//! - **`exec_cmd_in_pty`** — pre-buffers input, for non-interactive commands
//! - **`exec_cmd_in_pty_prompted`** — waits for prompt marker before each input
//! - **`exec_cmd_in_pty_prompted_with`** — also changes test state at a prompt
//!
//! ```ignore
//! use crate::common::pty::{build_pty_command, exec_cmd_in_pty_prompted};
//!
//! let cmd = build_pty_command("wt", &["switch", "feature"], dir, &env, None);
//! let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["y\n"], "[y/N");
//! ```
use portable_pty::{CommandBuilder, MasterPty};
use std::io::{Read, Write};
use std::path::Path;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
/// Read a PTY master to end-of-stream, returning its output lossily as UTF-8.
///
/// On Linux a `read` on the master returns `EIO` once the child has exited and
/// closed the slave side, instead of the clean 0-byte EOF macOS returns. Both
/// mean "the child is gone, nothing more to read", so `EIO` is treated as
/// end-of-stream here. `read_to_string().unwrap()` instead panicked
/// intermittently depending on whether the read raced ahead of or behind the
/// child's exit — #3144 was the same fragile read timing out on macOS, this is
/// its Linux face. Lossy UTF-8 (like the Windows branch of `read_pty_output`)
/// so a multibyte sequence truncated at `EIO` doesn't panic the test.
#[cfg(unix)]
pub fn read_pty_master_to_string<R: Read + ?Sized>(reader: &mut R) -> String {
use std::io::ErrorKind;
// POSIX `EIO` is 5 on every platform these tests run on (Linux, macOS);
// avoids a `libc` dev-dependency just for the constant.
const EIO: i32 = 5;
let mut bytes = Vec::new();
let mut chunk = [0u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) => break, // clean EOF (the macOS behavior)
Ok(n) => bytes.extend_from_slice(&chunk[..n]),
Err(e) if e.raw_os_error() == Some(EIO) => break,
// A transient `WouldBlock` (EAGAIN) means "no data yet", not EOF —
// retry after a short pause, mirroring the Windows branch of
// `read_pty_output`. The child will eventually produce data, close
// (EOF), or exit (EIO), so this can't spin forever. `read_to_string`
// didn't retry EAGAIN either, so it panicked here just the same;
// this is the more robust replacement.
Err(e) if e.kind() == ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(10));
continue;
}
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => panic!("PTY master read failed: {e}"),
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
/// Read a PTY master to end-of-stream on Windows (ConPTY), where a blocking
/// `read_to_string` reaches EOF cleanly.
#[cfg(not(unix))]
pub fn read_pty_master_to_string<R: Read + ?Sized>(reader: &mut R) -> String {
let mut buf = String::new();
reader.read_to_string(&mut buf).unwrap();
buf
}
/// Read output from PTY and wait for child exit.
///
/// On Unix, this simply reads to EOF then waits for child.
/// On Windows ConPTY, special handling is required because:
/// - The output pipe doesn't close when child exits (owned by pseudoconsole)
/// - ConPTY may send cursor position requests (ESC[6n) that must be answered
/// - ClosePseudoConsole must be called on a separate thread while draining output
///
/// See: https://learn.microsoft.com/en-us/windows/console/closepseudoconsole
pub fn read_pty_output(
reader: Box<dyn Read + Send>,
writer: Box<dyn Write + Send>,
master: Box<dyn MasterPty + Send>,
child: &mut Box<dyn portable_pty::Child + Send + Sync>,
) -> (String, i32) {
#[cfg(unix)]
{
let _ = master; // Not needed on Unix
// Drop writer to signal EOF to child's stdin (important for Unix PTYs)
drop(writer);
let mut reader = reader;
let buf = read_pty_master_to_string(&mut reader);
let exit_status = child.wait().unwrap();
(buf, exit_status.exit_code() as i32)
}
#[cfg(windows)]
{
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::Duration;
// Flag to signal the reader to stop
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_reader = should_stop.clone();
// Channel for the reader to send back the output
let (tx, rx) = mpsc::channel();
// Spawn reader thread that drains output in chunks and responds to cursor queries
let read_thread = thread::spawn(move || {
let mut reader = reader;
let mut writer = writer;
let mut output = Vec::new();
let mut temp_buf = [0u8; 4096];
loop {
// Check if we should stop
if should_stop_reader.load(Ordering::Relaxed) {
// Do one final read attempt with short timeout
// (output might still be in the pipe)
break;
}
// Read with a short timeout by using non-blocking behavior
// Unfortunately, portable_pty doesn't expose non-blocking reads,
// so we do blocking reads but with a timeout signal from the main thread
match reader.read(&mut temp_buf) {
Ok(0) => {
// EOF - pipe closed
break;
}
Ok(n) => {
let chunk = &temp_buf[..n];
output.extend_from_slice(chunk);
// Check for cursor position request (ESC[6n) and respond
// This is required when PSEUDOCONSOLE_INHERIT_CURSOR is set
if let Some(pos) = find_cursor_request(chunk) {
// Respond with cursor at position 1,1
// Format: ESC [ row ; col R
let response = b"\x1b[1;1R";
let _ = writer.write_all(response);
let _ = writer.flush();
// Log for debugging
eprintln!(
"ConPTY: Responded to cursor position request at byte {}",
pos
);
}
}
Err(e) => {
// Check if it's a "would block" or pipe closed error
if e.kind() == std::io::ErrorKind::WouldBlock {
thread::sleep(Duration::from_millis(10));
continue;
}
// Other errors - likely pipe closed
eprintln!("ConPTY: Read error: {}", e);
break;
}
}
}
let _ = tx.send(output);
});
// Wait for child to exit
let exit_status = child.wait().unwrap();
let exit_code = exit_status.exit_code() as i32;
// Signal the reader to stop
should_stop.store(true, Ordering::Relaxed);
// Close the master on a separate thread to avoid deadlock.
// This triggers ClosePseudoConsole which sends CTRL_CLOSE_EVENT
// and eventually closes the output pipe.
//
// We spawn this in parallel with recv_timeout because:
// 1. ClosePseudoConsole might block waiting for output to drain
// 2. We need to be checking for reader output while close happens
// 3. Without parallelism, we could deadlock
let close_thread = thread::spawn(move || {
drop(master);
});
// Wait for the reader to finish (with timeout).
// The close_thread runs in parallel, triggering pipe closure.
let output = match rx.recv_timeout(Duration::from_secs(10)) {
Ok(data) => data,
Err(_) => {
eprintln!("ConPTY: Read thread timed out after child exit");
Vec::new()
}
};
// Don't join either thread - they may be stuck in blocking operations:
// - read_thread may be stuck in read() waiting for data
// - close_thread may be stuck in ClosePseudoConsole waiting for reader to drain
//
// These form a potential deadlock: ClosePseudoConsole waits for reader,
// reader waits for ClosePseudoConsole to close the pipe.
//
// "Leaking" these threads is acceptable for test code - they'll be cleaned
// up when the test process exits. We already have the output (or timed out).
drop(close_thread);
drop(read_thread);
// Convert to string (lossy for any invalid UTF-8)
let buf = String::from_utf8_lossy(&output).to_string();
(buf, exit_code)
}
}
/// Find cursor position request (ESC[6n) in a byte slice.
/// Returns the position if found.
fn find_cursor_request(data: &[u8]) -> Option<usize> {
// Look for ESC [ 6 n sequence (0x1b 0x5b 0x36 0x6e)
let pattern = b"\x1b[6n";
data.windows(pattern.len())
.position(|window| window == pattern)
}
/// A PTY master writer shared between the reader thread (which answers terminal
/// queries) and the caller (which sends keystrokes). Both need to write to the
/// single master, and `portable_pty` hands out only one writer.
pub type SharedPtyWriter = Arc<Mutex<Box<dyn Write + Send>>>;
/// Spawn a thread that drains the PTY master `reader` into the returned channel
/// and answers the cursor-position report query (`ESC[6n`) skim emits while
/// initializing the picker.
///
/// skim 4.x runs the picker in partial-height mode, whose setup calls skim's
/// `cursor_pos_from_tty()`: it writes `ESC[6n` to `/dev/tty` and blocks in
/// `select()` for up to 3s waiting for the `ESC[row;colR` reply. A real terminal
/// answers automatically; `portable_pty` is a bare PTY with no emulation, so
/// without this reply skim fails init with "Cursor position detection timed out"
/// and the picker never renders. The reply (`1;1`) is a safe constant — skim only
/// uses it to place its inline viewport, which the TUI snapshot tests don't assert
/// on. Sharing `writer` with the caller lets keystrokes and query replies both
/// reach the master. The query is matched within a single read chunk; skim emits
/// the 4-byte DSR as one small write, so it never spans chunks.
pub fn spawn_pty_reader_answering_queries(
reader: Box<dyn Read + Send>,
writer: SharedPtyWriter,
) -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
let mut reader = reader;
let mut temp_buf = [0u8; 4096];
loop {
match reader.read(&mut temp_buf) {
Ok(0) => break,
Ok(n) => {
let chunk = &temp_buf[..n];
if find_cursor_request(chunk).is_some()
&& let Ok(mut w) = writer.lock()
{
let _ = w.write_all(b"\x1b[1;1R");
let _ = w.flush();
}
if tx.send(chunk.to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
rx
}
/// Build a CommandBuilder with standard PTY isolation and env vars.
///
/// Compose with `exec_cmd_in_pty` or `exec_cmd_in_pty_prompted`:
///
/// ```ignore
/// let cmd = build_pty_command("wt", &["switch", "feature"], dir, &env, None);
/// let (output, exit_code) = exec_cmd_in_pty(cmd, "y\n");
/// ```
pub fn build_pty_command(
command: &str,
args: &[&str],
working_dir: &Path,
env_vars: &[(String, String)],
home_dir: Option<&Path>,
) -> CommandBuilder {
let mut cmd = CommandBuilder::new(command);
for arg in args {
cmd.arg(*arg);
}
cmd.cwd(working_dir);
super::configure_pty_command(&mut cmd);
for (key, value) in env_vars {
cmd.env(key, value);
}
// Override HOME if provided (must be after configure_pty_command which sets HOME)
if let Some(home) = home_dir {
cmd.env("HOME", home.to_string_lossy().to_string());
cmd.env(
"XDG_CONFIG_HOME",
home.join(".config").to_string_lossy().to_string(),
);
#[cfg(windows)]
cmd.env("USERPROFILE", home.to_string_lossy().to_string());
}
cmd
}
/// Execute a CommandBuilder in a PTY, writing all input immediately.
///
/// Drops the writer before waiting for the child to signal EOF — non-interactive
/// commands may block on stdin until it closes.
///
/// For interactive prompts, use `exec_cmd_in_pty_prompted` instead (it waits
/// for the child before dropping the writer to avoid PTY echo artifacts).
pub fn exec_cmd_in_pty(cmd: CommandBuilder, input: &str) -> (String, i32) {
let pair = super::open_pty();
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let reader = pair.master.try_clone_reader().unwrap();
let mut writer = pair.master.take_writer().unwrap();
if !input.is_empty() {
writer.write_all(input.as_bytes()).unwrap();
writer.flush().unwrap();
}
let (buf, exit_code) = read_pty_output(reader, writer, pair.master, &mut child);
let normalized = buf.replace("\r\n", "\n");
(normalized, exit_code)
}
/// Execute a CommandBuilder in a PTY, waiting for prompts before sending input.
///
/// For each element of `inputs`, waits until `prompt_marker` appears in the
/// output, then writes that input. This produces output where the echo appears
/// after the prompt — matching real terminal behavior.
pub fn exec_cmd_in_pty_prompted(
cmd: CommandBuilder,
inputs: &[&str],
prompt_marker: &str,
) -> (String, i32) {
exec_cmd_in_pty_prompted_with(cmd, inputs, prompt_marker, |_| {})
}
/// Execute a command in a PTY, running an action after each prompt appears and
/// immediately before its input is sent.
///
/// This synchronizes tests that need to change external state inside the
/// preview/confirmation window without sleeps or timing assumptions.
pub fn exec_cmd_in_pty_prompted_with(
cmd: CommandBuilder,
inputs: &[&str],
prompt_marker: &str,
before_input: impl FnMut(usize),
) -> (String, i32) {
let pair = super::open_pty();
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let reader = pair.master.try_clone_reader().unwrap();
let writer = pair.master.take_writer().unwrap();
prompted_pty_interaction(
reader,
writer,
&mut child,
inputs,
prompt_marker,
before_input,
)
}
/// Core prompt-waiting logic shared by all `_prompted` variants.
///
/// Reads PTY output in a background thread while the main thread waits for
/// `prompt_marker` to appear before sending each input. After all inputs are
/// sent, waits for the child to exit, then drops the writer.
fn prompted_pty_interaction(
reader: Box<dyn std::io::Read + Send>,
writer: Box<dyn std::io::Write + Send>,
child: &mut Box<dyn portable_pty::Child + Send + Sync>,
inputs: &[&str],
prompt_marker: &str,
mut before_input: impl FnMut(usize),
) -> (String, i32) {
use std::sync::mpsc;
use std::time::{Duration, Instant};
let (tx, rx) = mpsc::channel::<Vec<u8>>();
// Read PTY output in background, sending chunks via channel
let reader_thread = std::thread::spawn(move || {
let mut reader = reader;
let mut buf = [0u8; 4096];
loop {
match std::io::Read::read(&mut reader, &mut buf) {
Ok(0) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let mut accumulated = Vec::new();
let mut writer = writer;
let timeout = Duration::from_secs(30);
let poll = Duration::from_millis(10);
let marker = prompt_marker.as_bytes();
// For each input, wait for a NEW prompt marker to appear, then send
let mut markers_seen: usize = 0;
for (input_index, input) in inputs.iter().enumerate() {
let target = markers_seen + 1;
let start = Instant::now();
loop {
while let Ok(chunk) = rx.try_recv() {
accumulated.extend_from_slice(&chunk);
}
if count_marker_occurrences(&accumulated, marker) >= target {
markers_seen = target;
break;
}
if start.elapsed() > timeout {
panic!(
"Timed out waiting for prompt marker {:?} (occurrence {}). Output so far:\n{}",
prompt_marker,
target,
String::from_utf8_lossy(&accumulated)
);
}
std::thread::sleep(poll);
}
// Quiescence drain: after detecting the marker, wait until the PTY
// goes quiet before sending input. Without this, trailing prompt bytes
// (ANSI resets, spaces) that arrive in a separate read chunk interleave
// with the echo of our input, producing non-deterministic output on macOS.
let quiescence = Duration::from_millis(20);
let drain_ceiling = Duration::from_millis(500);
let drain_start = Instant::now();
let mut last_data = Instant::now();
loop {
while let Ok(chunk) = rx.try_recv() {
accumulated.extend_from_slice(&chunk);
last_data = Instant::now();
}
if last_data.elapsed() >= quiescence {
break;
}
if drain_start.elapsed() >= drain_ceiling {
break;
}
std::thread::sleep(poll);
}
before_input(input_index);
writer.write_all(input.as_bytes()).unwrap();
writer.flush().unwrap();
}
// Wait for child to exit BEFORE dropping writer.
//
// portable_pty's UnixMasterWriter::drop() sends \n + EOT to the PTY.
// If dropped while the child is still running, the terminal echoes this
// \n as \r\n, creating a spurious blank line in the captured output.
// By waiting for the child first, the slave side closes and the echo
// from the Drop's \n goes to a dead PTY — no artifact.
//
// The child won't hang: after read_line() returns for all prompts, it
// continues executing without reading stdin. EOF isn't needed.
let exit_status = child.wait().unwrap();
let exit_code = exit_status.exit_code() as i32;
// Now safe to drop writer (child already exited, slave side closed)
drop(writer);
// Wait for reader thread to finish
let _ = reader_thread.join();
// Drain any remaining chunks
while let Ok(chunk) = rx.try_recv() {
accumulated.extend_from_slice(&chunk);
}
let buf = String::from_utf8_lossy(&accumulated).to_string();
let normalized = buf.replace("\r\n", "\n");
(normalized, exit_code)
}
fn count_marker_occurrences(haystack: &[u8], needle: &[u8]) -> usize {
if needle.is_empty() || needle.len() > haystack.len() {
return 0;
}
haystack
.windows(needle.len())
.filter(|w| *w == needle)
.count()
}