use std::io::{Read, Write};
use std::sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::thread::{self, JoinHandle};
use std::time::Instant;
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
use crate::clipboard::{Clipboard, Osc52Extractor};
use crate::pty_state_tracker::PtyPerformAdapter;
const PTY_READ_BUF_SIZE: usize = 65536;
const HISTORY_TAIL_LEN: usize = 8;
const DSR_PATTERN_LEN: usize = 4;
#[cfg(target_os = "macos")]
const PROC_NAME_BUF_SIZE: usize = 64;
const FOREGROUND_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
use crate::PtyStatus;
use crate::title::extract_osc_title;
pub type PtyResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
type StatusCallback = Arc<Mutex<Option<Box<dyn Fn(PtyStatus) + Send + Sync>>>>;
#[derive(Clone)]
pub struct PtyWriter {
inner: Arc<Mutex<Box<dyn Write + Send>>>,
}
impl PtyWriter {
fn new(writer: Box<dyn Write + Send>) -> Self {
Self {
inner: Arc::new(Mutex::new(writer)),
}
}
pub fn write_bytes(&self, input: &[u8]) -> std::io::Result<()> {
let mut writer = self.inner.lock().unwrap_or_else(|err| err.into_inner());
writer.write_all(input)?;
writer.flush()
}
}
pub struct Pty {
master: Box<dyn MasterPty + Send>,
writer: PtyWriter,
pending: Arc<Mutex<Vec<u8>>>,
bytes_received: Arc<AtomicUsize>,
last_bytes: Arc<Mutex<Vec<u8>>>,
dsr_requested: Arc<AtomicBool>,
pending_title: Arc<Mutex<Option<String>>>,
foreground_title: Arc<Mutex<Option<String>>>,
last_fg_pid: u32,
last_fg_check: Instant,
pub(crate) shared_parser: Arc<Mutex<vt100::Parser>>,
pub(crate) dirty: Arc<AtomicBool>,
pub(crate) dirty_cond: Arc<(Mutex<()>, Condvar)>,
size: PtySize,
pty_size: PtySize,
scrollback_len: usize,
child: Option<Box<dyn Child + Send + Sync>>,
exited: bool,
exit_status: Option<portable_pty::ExitStatus>,
exited_emitted: Arc<AtomicBool>,
reader: Option<JoinHandle<()>>,
status_cb: StatusCallback,
pub(crate) tracker: std::sync::Arc<crate::PtyStateTracker>,
shutdown: Arc<AtomicBool>,
}
pub struct PtyParts {
pub child: Option<Box<dyn Child + Send + Sync>>,
pub reader_handle: Option<JoinHandle<()>>,
}
impl Pty {
pub fn spawn(command: CommandBuilder, size: PtySize) -> PtyResult<Self> {
Self::spawn_with_scrollback(command, size, 0)
}
pub fn spawn_with_scrollback(
command: CommandBuilder,
size: PtySize,
scrollback_len: usize,
) -> PtyResult<Self> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(size)
.map_err(|err| wrap_err("openpty", err))?;
let child = pair
.slave
.spawn_command(command)
.map_err(|err| wrap_err("spawn_command", err))?;
let reader = pair
.master
.try_clone_reader()
.map_err(|err| wrap_err("try_clone_reader", err))?;
let writer = PtyWriter::new(
pair.master
.take_writer()
.map_err(|err| wrap_err("take_writer", err))?,
);
let pending = Arc::new(Mutex::new(Vec::new()));
let bytes_received = Arc::new(AtomicUsize::new(0));
let last_bytes = Arc::new(Mutex::new(Vec::new()));
let dsr_requested = Arc::new(AtomicBool::new(false));
let reader_pending = Arc::clone(&pending);
let reader_bytes = Arc::clone(&bytes_received);
let reader_last = Arc::clone(&last_bytes);
let reader_dsr = Arc::clone(&dsr_requested);
let status_cb: StatusCallback = Arc::new(Mutex::new(None));
let reader_status_cb = Arc::clone(&status_cb);
let exited_emitted = Arc::new(AtomicBool::new(false));
let reader_exited_emitted = Arc::clone(&exited_emitted);
let pending_title = Arc::new(Mutex::new(None));
let foreground_title = Arc::new(Mutex::new(None));
let initial_parser = vt100::Parser::new(size.rows, size.cols, scrollback_len);
let tracker = std::sync::Arc::new(crate::PtyStateTracker::new(size.rows));
let reader_tracker = std::sync::Arc::clone(&tracker);
let shared_parser = Arc::new(Mutex::new(initial_parser));
let dirty = Arc::new(AtomicBool::new(false));
let dirty_cond = Arc::new((Mutex::new(()), Condvar::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let reader_parser = Arc::clone(&shared_parser);
let reader_dirty = Arc::clone(&dirty);
let reader_dirty_cond = Arc::clone(&dirty_cond);
let reader_pending_title = Arc::clone(&pending_title);
let reader_handle = thread::spawn(move || {
parser_read_loop(ParserReadLoopArgs {
reader,
pending: reader_pending,
bytes_received: reader_bytes,
last_bytes: reader_last,
dsr_requested: reader_dsr,
shared_parser: reader_parser,
dirty: reader_dirty,
dirty_cond: reader_dirty_cond,
pending_title: reader_pending_title,
status_cb: reader_status_cb,
scrollback_len,
osc52_text: None,
clipboard: None,
exited_emitted: reader_exited_emitted,
tracker: reader_tracker,
})
});
Ok(Self {
master: pair.master,
writer,
pending,
bytes_received,
last_bytes,
dsr_requested,
pending_title,
foreground_title,
last_fg_pid: 0,
last_fg_check: Instant::now(),
shared_parser,
dirty,
dirty_cond,
tracker,
size,
pty_size: size,
scrollback_len,
child: Some(child),
exited: false,
exit_status: None,
reader: Some(reader_handle),
status_cb,
exited_emitted,
shutdown,
})
}
pub fn set_status_callback(&mut self, cb: Option<Box<dyn Fn(PtyStatus) + Send + Sync>>) {
let mut fire_cb = None;
if let Ok(mut guard) = self.status_cb.lock() {
if self.exited {
self.exited_emitted.store(true, Ordering::Release);
fire_cb = cb;
*guard = None;
} else if let Some(child) = self.child.as_mut()
&& let Ok(Some(status)) = child.try_wait()
{
self.exited = true;
self.exit_status = Some(status);
self.child = None;
self.exited_emitted.store(true, Ordering::Release);
fire_cb = cb;
*guard = None;
} else {
*guard = cb;
}
}
if let Some(reader) = &self.reader {
reader.thread().unpark();
}
if let Some(cb_fn) = fire_cb {
cb_fn(PtyStatus::Exited);
}
}
pub fn into_parts(&mut self) -> PtyParts {
self.shutdown.store(true, Ordering::Release);
if let Some(reader) = &self.reader {
reader.thread().unpark();
}
PtyParts {
child: self.child.take(),
reader_handle: self.reader.take(),
}
}
pub fn reader_is_alive(&self) -> bool {
self.reader.is_some()
}
pub fn resize(&mut self, size: PtySize) -> PtyResult<()> {
if size.rows < 2 || size.cols < 2 {
return Ok(());
}
if size == self.pty_size {
return Ok(());
}
let sp = self.shared_parser.clone();
let mut guard = sp.lock().unwrap();
let old_rows = self.pty_size.rows;
let new_rows = size.rows;
if new_rows < old_rows && !self.tracker.has_custom_margins() {
let (cursor_row, _) = guard.screen().cursor_position();
if cursor_row >= new_rows {
let scroll_lines = cursor_row - new_rows + 1;
let seq = format!("[{scroll_lines}S");
guard.process(seq.as_bytes());
}
}
self.master
.resize(size)
.map_err(|err| wrap_err("resize", err))?;
guard.screen_mut().set_size(size.rows, size.cols);
drop(guard);
self.tracker.resize(size.rows);
self.pty_size = size;
self.size = size;
Ok(())
}
pub fn write_bytes(&mut self, input: &[u8]) -> std::io::Result<()> {
self.writer.write_bytes(input)
}
pub fn writer_handle(&self) -> PtyWriter {
self.writer.clone()
}
pub fn write_str(&mut self, input: &str) -> std::io::Result<()> {
self.write_bytes(input.as_bytes())
}
pub fn take_pending_title(&self) -> Option<String> {
let fg = self
.foreground_title
.lock()
.unwrap_or_else(|err| err.into_inner())
.clone();
if fg.is_some() {
let _ = self
.pending_title
.lock()
.unwrap_or_else(|err| err.into_inner())
.take();
return fg;
}
self.pending_title
.lock()
.unwrap_or_else(|err| err.into_inner())
.take()
}
fn poll_foreground(&mut self) {
if self.last_fg_check.elapsed() >= FOREGROUND_POLL_INTERVAL {
self.last_fg_check = Instant::now();
if let Some(fg_pid) = self.foreground_pid()
&& fg_pid != self.last_fg_pid
{
self.last_fg_pid = fg_pid;
let name = get_process_name(fg_pid);
*self
.foreground_title
.lock()
.unwrap_or_else(|err| err.into_inner()) = name;
}
}
}
#[cfg(unix)]
fn foreground_pid(&self) -> Option<u32> {
self.master.process_group_leader().map(|p| p as u32)
}
#[cfg(windows)]
fn foreground_pid(&self) -> Option<u32> {
let shell_pid = self.child.as_ref().and_then(|c| c.process_id())?;
find_foreground_process_windows(shell_pid)
}
#[cfg(not(any(unix, windows)))]
fn foreground_pid(&self) -> Option<u32> {
None
}
pub fn drain_pending(&mut self) -> Vec<u8> {
let mut pending = self.pending.lock().unwrap_or_else(|err| err.into_inner());
pending.split_off(0)
}
pub fn screen_lines(&mut self) -> Vec<String> {
self.screen(); let parser = self.shared_parser.lock().unwrap();
let screen = parser.screen();
let contents = screen.contents();
let mut lines: Vec<String> = contents.lines().map(|line| line.to_string()).collect();
if lines.len() < self.size.rows as usize {
lines.resize(self.size.rows as usize, String::new());
}
lines
}
pub fn has_exited(&mut self) -> bool {
if self.exited {
return true;
}
let Some(child) = self.child.as_mut() else {
return true;
};
match child.try_wait() {
Ok(Some(status)) => {
self.exited = true;
self.exit_status = Some(status);
self.child = None;
if let Ok(guard) = self.status_cb.lock()
&& let Some(ref cb) = *guard
&& !self.exited_emitted.swap(true, Ordering::AcqRel)
{
cb(crate::PtyStatus::Exited);
}
true
}
Ok(None) => false,
Err(_) => false,
}
}
pub fn exit_status(&self) -> Option<portable_pty::ExitStatus> {
self.exit_status.clone()
}
pub fn take_exit_status(&mut self) -> Option<portable_pty::ExitStatus> {
self.exit_status.take()
}
pub fn kill_child(&mut self) -> PtyResult<()> {
if let Some(mut child) = self.child.take() {
child.kill().map_err(|err| wrap_err("kill", err))?;
self.exited = true;
self.child = None;
}
Ok(())
}
#[cfg(unix)]
pub fn process_group_id(&self) -> Option<i32> {
self.master.process_group_leader()
}
#[cfg(unix)]
pub fn signal_process_group(&self, signal: i32) -> PtyResult<()> {
let Some(pgid) = self.master.process_group_leader() else {
return Err(wrap_err(
"signal_process_group",
std::io::Error::new(
std::io::ErrorKind::NotFound,
"PTY child has no process group",
),
));
};
let ret = unsafe { libc::kill(-pgid, signal) };
if ret == -1 {
return Err(wrap_err(
"signal_process_group",
std::io::Error::last_os_error(),
));
}
Ok(())
}
pub fn size(&self) -> PtySize {
self.size
}
pub fn screen(&mut self) {
self.poll_foreground();
if self.dirty.swap(false, Ordering::Acquire) {
if self.dsr_requested.swap(false, Ordering::Relaxed) {
let parser = self.shared_parser.lock().unwrap();
let (row, col) = parser.screen().cursor_position();
drop(parser);
let response = format!("\x1b[{};{}R", row.saturating_add(1), col.saturating_add(1));
let _ = self.write_bytes(response.as_bytes());
}
let (lock, cvar) = &*self.dirty_cond;
let _guard = lock.lock().unwrap();
cvar.notify_all();
}
}
pub fn generate_snapshot(&mut self) -> Vec<u8> {
self.screen();
let parser = self.shared_parser.lock().unwrap();
parser.screen().state_formatted()
}
pub fn bytes_received(&self) -> usize {
self.bytes_received.load(Ordering::Relaxed)
}
pub fn last_bytes_text(&self) -> String {
let bytes = self
.last_bytes
.lock()
.map(|buf| buf.clone())
.unwrap_or_default();
bytes_to_debug_text(&bytes, 32)
}
pub fn scrollback(&mut self) -> usize {
self.screen(); let parser = self.shared_parser.lock().unwrap();
parser.screen().scrollback()
}
pub fn set_scrollback(&mut self, rows: usize) {
let max = self.scrollback_len;
let mut parser = self.shared_parser.lock().unwrap();
parser.screen_mut().set_scrollback(rows.min(max));
}
pub fn scrollback_len(&self) -> usize {
self.scrollback_len
}
pub fn max_scrollback(&mut self) -> usize {
let max_sb = self.scrollback_len;
if max_sb == 0 {
return 0;
}
let mut parser = self.shared_parser.lock().unwrap();
let screen = parser.screen_mut();
let current = screen.scrollback();
screen.set_scrollback(max_sb);
let max = screen.scrollback();
screen.set_scrollback(current);
max
}
pub fn alternate_screen(&mut self) -> bool {
self.screen(); let parser = self.shared_parser.lock().unwrap();
parser.screen().alternate_screen()
}
pub fn direct_input_tracker(&self) -> std::sync::Arc<dyn crate::DirectInputTracker> {
self.tracker.clone()
}
}
impl Drop for Pty {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Release);
if let Some(reader) = &self.reader {
reader.thread().unpark();
}
}
}
struct ParserReadLoopArgs {
reader: Box<dyn Read + Send>,
pending: Arc<Mutex<Vec<u8>>>,
bytes_received: Arc<AtomicUsize>,
last_bytes: Arc<Mutex<Vec<u8>>>,
dsr_requested: Arc<AtomicBool>,
shared_parser: Arc<Mutex<vt100::Parser>>,
dirty: Arc<AtomicBool>,
dirty_cond: Arc<(std::sync::Mutex<()>, Condvar)>,
pending_title: Arc<Mutex<Option<String>>>,
status_cb: StatusCallback,
scrollback_len: usize,
exited_emitted: Arc<AtomicBool>,
osc52_text: Option<Arc<Mutex<Option<String>>>>,
clipboard: Option<Clipboard>,
tracker: std::sync::Arc<crate::PtyStateTracker>,
}
fn parser_read_loop(args: ParserReadLoopArgs) {
let ParserReadLoopArgs {
mut reader,
pending,
bytes_received,
last_bytes,
dsr_requested,
shared_parser,
dirty,
dirty_cond,
pending_title,
status_cb,
scrollback_len: _scrollback_len,
osc52_text,
clipboard,
exited_emitted,
tracker,
} = args;
let mut prev_tail: [u8; HISTORY_TAIL_LEN] = [0; HISTORY_TAIL_LEN];
let mut buf = [0u8; PTY_READ_BUF_SIZE];
let mut osc52 = Osc52Extractor::new();
let mut bytes_since_render = 0usize;
let mut vte_parser = vte::Parser::new();
let tracker_for_adapter = std::sync::Arc::clone(&tracker);
let mut tracker_adapter = PtyPerformAdapter::new(tracker_for_adapter);
let mut clipboard = clipboard.unwrap_or_else(Clipboard::new);
const IO_BURST_BUDGET: usize = 256 * 1024; loop {
match reader.read(&mut buf) {
Ok(0) => {
if let Some(text) = osc52.finish() {
clipboard.set(&text);
if let Some(ref capture) = osc52_text {
*capture.lock().unwrap() = Some(text);
}
}
if let Ok(guard) = status_cb.lock()
&& let Some(ref cb) = *guard
{
cb(crate::PtyStatus::Wakeup);
if !exited_emitted.swap(true, Ordering::AcqRel) {
cb(crate::PtyStatus::Exited);
}
}
break;
}
Ok(n) => {
bytes_received.fetch_add(n, Ordering::Relaxed);
bytes_since_render += n;
if buf[..n].windows(DSR_PATTERN_LEN).any(|w| w == b"\x1b[6n") {
dsr_requested.store(true, Ordering::Relaxed);
}
if let Ok(mut last) = last_bytes.lock() {
last.clear();
last.extend_from_slice(&buf[..n]);
}
if let Ok(mut p) = pending.lock() {
p.extend_from_slice(&buf[..n]);
const PENDING_CAP: usize = 1024 * 1024; if p.len() > PENDING_CAP {
p.clear();
}
}
let prev_routing = tracker.requires_app_routing();
vte_parser.advance(&mut tracker_adapter, &buf[..n]);
let new_routing = tracker.requires_app_routing();
if prev_routing != new_routing {
tracing::info!(
"[STAGE 1] PTY routing flipped: {} -> {}",
prev_routing,
new_routing
);
if let Ok(guard) = status_cb.lock()
&& let Some(ref cb) = *guard
{
cb(crate::PtyStatus::DirectInputChanged(new_routing));
} else {
tracing::error!("[STAGE 1] status_cb is NONE when transition occurred!");
}
}
{
let mut shared = shared_parser.lock().unwrap();
shared.process(&buf[..n]);
}
if let Some(title) = extract_osc_title(&buf[..n])
&& let Ok(mut guard) = pending_title.lock()
{
*guard = Some(title);
}
if let Some(text) = osc52.push(&buf[..n], &prev_tail) {
clipboard.set(&text);
if let Some(ref capture) = osc52_text {
*capture.lock().unwrap() = Some(text);
}
}
if n >= HISTORY_TAIL_LEN {
prev_tail.copy_from_slice(&buf[n - HISTORY_TAIL_LEN..n]);
} else if n > 0 {
prev_tail.rotate_left(n);
prev_tail[HISTORY_TAIL_LEN - n..].copy_from_slice(&buf[..n]);
}
if !dirty.swap(true, Ordering::AcqRel) {
if let Ok(guard) = status_cb.lock()
&& let Some(ref cb) = *guard
{
cb(crate::PtyStatus::Wakeup);
}
bytes_since_render = 0;
}
if bytes_since_render >= IO_BURST_BUDGET {
let (lock, cvar) = &*dirty_cond;
let mut guard = lock.lock().unwrap();
while dirty.load(Ordering::Acquire) {
guard = cvar.wait(guard).unwrap();
}
bytes_since_render = 0;
}
}
Err(_) => {
if let Ok(guard) = status_cb.lock()
&& let Some(ref cb) = *guard
&& !exited_emitted.swap(true, Ordering::AcqRel)
{
cb(crate::PtyStatus::Exited);
}
break;
}
}
}
}
fn wrap_err<E: std::fmt::Display>(
stage: &'static str,
err: E,
) -> Box<dyn std::error::Error + Send + Sync> {
Box::new(std::io::Error::other(format!("pty {stage} failed: {err}")))
}
fn bytes_to_debug_text(bytes: &[u8], max_len: usize) -> String {
let mut out = String::new();
for &b in bytes.iter().take(max_len) {
match b {
b'\r' => out.push_str("\\r"),
b'\n' => out.push_str("\\n"),
b'\t' => out.push_str("\\t"),
0x20..=0x7e => out.push(b as char),
_ => out.push_str(&format!("\\x{:02x}", b)),
}
}
out
}
#[cfg(target_os = "macos")]
fn get_process_name(pid: u32) -> Option<String> {
let mut name = [0u8; PROC_NAME_BUF_SIZE];
let result = unsafe {
libc::proc_name(
pid as libc::c_int,
name.as_mut_ptr() as *mut libc::c_void,
name.len() as u32,
)
};
if result > 0 {
let len = name.iter().position(|&b| b == 0).unwrap_or(name.len());
Some(String::from_utf8_lossy(&name[..len]).into_owned())
} else {
None
}
}
#[cfg(target_os = "linux")]
fn get_process_name(pid: u32) -> Option<String> {
let path = format!("/proc/{pid}/comm");
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_string())
}
#[cfg(windows)]
fn get_process_name(pid: u32) -> Option<String> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
let handle = unsafe { kernel32::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle == 0 {
return None;
}
let mut buf = [0u16; 260];
let mut size = buf.len() as u32;
let result =
unsafe { kernel32::QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size) };
unsafe {
kernel32::CloseHandle(handle);
}
if result == 0 {
return None;
}
let path = OsString::from_wide(&buf[..size as usize]);
std::path::Path::new(&path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
}
#[cfg(windows)]
fn find_foreground_process_windows(shell_pid: u32) -> Option<u32> {
let snapshot = unsafe { kernel32::CreateToolhelp32Snapshot(0x00000002, 0) };
if snapshot == kernel32::INVALID_HANDLE_VALUE {
return None;
}
let mut children: Vec<(u32, u32)> = Vec::new();
let mut entry = std::mem::MaybeUninit::<kernel32::PROCESSENTRY32W>::zeroed();
unsafe {
(*entry.as_mut_ptr()).dwSize = std::mem::size_of::<kernel32::PROCESSENTRY32W>() as u32;
if kernel32::Process32FirstW(snapshot, entry.as_mut_ptr()) != 0 {
loop {
let e = entry.assume_init();
children.push((e.th32ProcessID, e.th32ParentProcessID));
if kernel32::Process32NextW(snapshot, entry.as_mut_ptr()) == 0 {
break;
}
}
}
kernel32::CloseHandle(snapshot);
}
let mut current = shell_pid;
loop {
let next = children
.iter()
.find(|&&(pid, parent)| parent == current && pid != current)
.map(|&(pid, _)| pid);
match next {
Some(next) => current = next,
None => break,
}
}
Some(current)
}
#[cfg(windows)]
mod kernel32 {
pub const INVALID_HANDLE_VALUE: isize = -1;
#[repr(C)]
#[derive(Copy, Clone)]
#[allow(non_snake_case)]
pub struct PROCESSENTRY32W {
pub dwSize: u32,
pub cntUsage: u32,
pub th32ProcessID: u32,
pub th32DefaultHeapID: usize,
pub th32ModuleID: u32,
pub cntThreads: u32,
pub th32ParentProcessID: u32,
pub pcPriClassBase: i32,
pub dwFlags: u32,
pub szExeFile: [u16; 260],
}
#[allow(non_snake_case)]
unsafe extern "system" {
pub fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> isize;
pub fn Process32FirstW(hSnapshot: isize, lppe: *mut PROCESSENTRY32W) -> i32;
pub fn Process32NextW(hSnapshot: isize, lppe: *mut PROCESSENTRY32W) -> i32;
pub fn CloseHandle(hObject: isize) -> i32;
pub fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> isize;
pub fn QueryFullProcessImageNameW(
hProcess: isize,
dwFlags: u32,
lpExeName: *mut u16,
lpdwSize: *mut u32,
) -> i32;
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
fn get_process_name(_pid: u32) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pty::StatusCallback;
use std::io;
use std::io::Cursor;
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, RwLock};
fn get_test_executable() -> &'static str {
#[cfg(target_os = "windows")]
{
"cmd.exe"
}
#[cfg(not(target_os = "windows"))]
{
"cat"
}
}
#[test]
fn bytes_to_debug_text_empty() {
assert_eq!(bytes_to_debug_text(b"", 32), "");
}
#[test]
fn bytes_to_debug_text_printable_passthrough() {
assert_eq!(bytes_to_debug_text(b"hello world", 32), "hello world");
}
#[test]
fn bytes_to_debug_text_encodes_control_and_nonprint() {
let data = b"a\nb\tc\r\x01\xff";
let s = bytes_to_debug_text(data, 32);
assert!(s.contains("a\\nb\\tc\\r"));
assert!(s.contains("\\x01"));
assert!(s.contains("\\xff"));
}
#[test]
fn bytes_to_debug_text_truncates_at_max_len() {
let long = b"abcdefghijklmnopqrstuvwxyz";
assert_eq!(bytes_to_debug_text(long, 5).len(), 5);
}
#[test]
fn bytes_to_debug_text_short_max_len() {
let s = bytes_to_debug_text(b"hello", 0);
assert_eq!(s, "");
}
#[test]
fn bytes_to_debug_text_all_control_chars() {
let data: Vec<u8> = (0..32).collect();
let s = bytes_to_debug_text(&data, 64);
for i in 0..32u8 {
let expected = match i {
0x09 => 't',
0x0a => 'n',
0x0d => 'r',
_ => continue,
};
assert!(
s.contains(&format!("\\{}", expected)),
"missing named escape for 0x{i:02x}"
);
}
assert!(s.contains("\\x00"));
assert!(s.contains("\\x01"));
assert!(s.contains("\\x1b"));
assert!(s.contains("\\x1f"));
}
fn make_parser_test_args() -> ParserReadLoopArgs {
ParserReadLoopArgs {
reader: Box::new(Cursor::new(Vec::new())),
pending: Arc::new(Mutex::new(Vec::new())),
bytes_received: Arc::new(AtomicUsize::new(0)),
last_bytes: Arc::new(Mutex::new(Vec::new())),
dsr_requested: Arc::new(AtomicBool::new(false)),
shared_parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
dirty: Arc::new(AtomicBool::new(false)),
dirty_cond: Arc::new((Mutex::new(()), Condvar::new())),
pending_title: Arc::new(Mutex::new(None)),
status_cb: Arc::new(Mutex::new(None)),
scrollback_len: 0,
exited_emitted: Arc::new(AtomicBool::new(false)),
osc52_text: None,
clipboard: None,
tracker: std::sync::Arc::new(crate::PtyStateTracker::new(24)),
}
}
#[test]
fn parser_read_loop_reads_and_sets_pending_and_last() {
let payload = b"hello\r\n\x1b[6nworld";
let mut args = make_parser_test_args();
args.reader = Box::new(Cursor::new(payload.to_vec()));
let pending = Arc::clone(&args.pending);
let bytes_received = Arc::clone(&args.bytes_received);
let last_bytes = Arc::clone(&args.last_bytes);
let dsr_requested = Arc::clone(&args.dsr_requested);
let dirty = Arc::clone(&args.dirty);
parser_read_loop(args);
let p = pending.lock().unwrap();
assert!(!p.is_empty());
assert!(bytes_received.load(Ordering::Relaxed) > 0);
let last = last_bytes.lock().unwrap();
assert!(!last.is_empty());
assert!(dsr_requested.load(Ordering::Relaxed));
assert!(dirty.load(Ordering::Relaxed));
}
#[test]
fn parser_read_loop_empty_input() {
let mut args = make_parser_test_args();
args.reader = Box::new(Cursor::new(Vec::new()));
let pending = Arc::clone(&args.pending);
let bytes_received = Arc::clone(&args.bytes_received);
let last_bytes = Arc::clone(&args.last_bytes);
let dsr_requested = Arc::clone(&args.dsr_requested);
let dirty = Arc::clone(&args.dirty);
parser_read_loop(args);
let p = pending.lock().unwrap();
assert!(p.is_empty());
assert_eq!(bytes_received.load(Ordering::Relaxed), 0);
let last = last_bytes.lock().unwrap();
assert!(last.is_empty());
assert!(!dsr_requested.load(Ordering::Relaxed));
assert!(!dirty.load(Ordering::Relaxed));
}
#[test]
fn parser_read_loop_status_callback_called_when_set() {
let payload = b"data";
let mut args = make_parser_test_args();
args.reader = Box::new(Cursor::new(payload.to_vec()));
let woke = Arc::new(AtomicBool::new(false));
let woke_clone = Arc::clone(&woke);
if let Ok(mut guard) = args.status_cb.lock() {
*guard = Some(Box::new(move |status| {
if status == crate::PtyStatus::Wakeup {
woke_clone.store(true, Ordering::Relaxed);
}
}));
}
parser_read_loop(args);
assert!(
woke.load(Ordering::Relaxed),
"status callback must be invoked on wakeup"
);
}
#[test]
fn parser_read_loop_tracks_tail_for_cross_boundary_dsr() {
let payload = b"XX\x1b[6nYY";
let mut args = make_parser_test_args();
args.reader = Box::new(Cursor::new(payload.to_vec()));
let dsr_requested = Arc::clone(&args.dsr_requested);
parser_read_loop(args);
assert!(
dsr_requested.load(Ordering::Relaxed),
"DSR in combined data must be detected"
);
}
#[test]
fn parser_read_loop_relays_osc52_to_isolated_clipboard_and_hook() {
let shared = Arc::new(RwLock::new(None));
let mut args = make_parser_test_args();
args.clipboard = Some(Clipboard::with_shared_buffer(Arc::clone(&shared)));
let captured = Arc::new(Mutex::new(None));
args.osc52_text = Some(Arc::clone(&captured));
args.reader = Box::new(Cursor::new(crate::clipboard::format_osc52_bytes(
"clip via pty",
)));
parser_read_loop(args);
let captured = captured.lock().unwrap();
assert_eq!(
captured.as_deref(),
Some("clip via pty"),
"osc52_text hook must capture the relayed payload"
);
assert_eq!(
*shared.read().unwrap(),
Some("clip via pty".to_string()),
"relayed set() must have written the shared in-memory buffer"
);
}
#[test]
fn set_status_callback_fires_from_spawn() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
let woke = Arc::new(AtomicBool::new(false));
let woke_cb = Arc::clone(&woke);
pty.set_status_callback(Some(Box::new(move |status| {
if status == crate::PtyStatus::Wakeup {
woke_cb.store(true, Ordering::Relaxed);
}
})));
let _ = pty.write_str("hello\n");
for _ in 0..250 {
if woke.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
assert!(
woke.load(Ordering::Relaxed),
"status callback must fire on Wakeup when PTY outputs data"
);
}
#[test]
fn has_exited_fires_exited_callback_when_child_dies() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
let exited_fired = Arc::new(AtomicBool::new(false));
let exited_cb = Arc::clone(&exited_fired);
pty.set_status_callback(Some(Box::new(move |status| {
if status == crate::PtyStatus::Exited {
exited_cb.store(true, Ordering::Relaxed);
}
})));
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
for _ in 0..250 {
if pty.has_exited() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(
exited_fired.load(Ordering::Relaxed),
"has_exited() must fire PtyStatus::Exited callback when child dies"
);
}
#[test]
fn has_exited_idempotent_after_child_exits() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
let exit_count = Arc::new(AtomicUsize::new(0));
let count_cb = Arc::clone(&exit_count);
pty.set_status_callback(Some(Box::new(move |status| {
if status == crate::PtyStatus::Exited {
count_cb.fetch_add(1, Ordering::Relaxed);
}
})));
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
for _ in 0..250 {
if pty.has_exited() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let count_after_first = exit_count.load(Ordering::Relaxed);
assert!(pty.has_exited(), "second call must also return true");
std::thread::sleep(std::time::Duration::from_millis(100));
let count_after_second = exit_count.load(Ordering::Relaxed);
assert_eq!(
count_after_first, count_after_second,
"has_exited() must not re-fire the Exited callback after returning true"
);
}
#[test]
fn set_status_callback_fires_when_child_already_exited() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
for _ in 0..250 {
if pty.has_exited() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let exited_fired = Arc::new(AtomicBool::new(false));
let exited_cb = Arc::clone(&exited_fired);
pty.set_status_callback(Some(Box::new(move |status| {
if status == PtyStatus::Exited {
exited_cb.store(true, Ordering::Relaxed);
}
})));
assert!(
exited_fired.load(Ordering::Relaxed),
"set_status_callback must fire PtyStatus::Exited when child already exited"
);
}
#[test]
fn set_status_callback_fires_via_try_wait_after_direct_kill() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
for _ in 0..250 {
if let Some(Ok(Some(_))) = pty.child.as_mut().map(|c| c.try_wait()) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let exited_fired = Arc::new(AtomicBool::new(false));
let exited_cb = Arc::clone(&exited_fired);
pty.set_status_callback(Some(Box::new(move |status| {
if status == PtyStatus::Exited {
exited_cb.store(true, Ordering::Relaxed);
}
})));
assert!(
exited_fired.load(Ordering::Relaxed),
"set_status_callback must fire PtyStatus::Exited via try_wait() when child killed"
);
}
#[test]
fn screen_loads_from_shared_parser_when_dirty() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
pty.screen();
let mut new_parser = vt100::Parser::new(24, 80, 100);
new_parser.process(b"hello world");
{
let mut shared = pty.shared_parser.lock().unwrap();
*shared = new_parser;
}
pty.dirty.store(true, Ordering::Release);
pty.screen();
{
let parser = pty.shared_parser.lock().unwrap();
if let Some(cell) = parser.screen().cell(0, 0) {
let contents = cell.contents();
assert!(
contents.contains('h'),
"expected 'h' from new screen, got {contents:?}"
);
}
}
assert!(!pty.dirty.load(Ordering::Acquire), "dirty must be cleared");
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn screen_syncs_from_shared_parser() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
let mut new_parser = vt100::Parser::new(24, 80, 100);
new_parser.process(b"content");
{
let mut shared = pty.shared_parser.lock().unwrap();
*shared = new_parser;
}
pty.dirty.store(true, Ordering::Release);
pty.screen();
{
let parser = pty.shared_parser.lock().unwrap();
let cell = parser.screen().cell(0, 0);
assert!(cell.is_some(), "expected a cell at (0,0)");
assert_eq!(cell.unwrap().contents(), "c");
}
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn set_scrollback_mutation_visible_through_scrollback_and_screen() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
pty.screen();
let mut lines = Vec::new();
for i in 0..30 {
writeln!(lines, "line {}", i).unwrap();
}
let mut parser = vt100::Parser::new(24, 80, 100);
parser.process(&lines);
{
let mut shared = pty.shared_parser.lock().unwrap();
*shared = parser;
}
pty.dirty.store(true, Ordering::Release);
pty.screen();
assert_eq!(pty.scrollback(), 0);
let sb_available = pty.max_scrollback();
assert!(
sb_available >= 3,
"need at least 3 scrollback lines, got {sb_available}"
);
pty.set_scrollback(3);
assert_eq!(
pty.scrollback(),
3,
"scrollback() must reflect set_scrollback"
);
{
let shared = pty.shared_parser.lock().unwrap();
assert_eq!(
shared.screen().scrollback(),
3,
"shared parser's screen must reflect set_scrollback"
);
}
pty.screen();
assert_eq!(
pty.scrollback(),
3,
"mutation must survive repeated screen() calls without new data"
);
let mut parser2 = vt100::Parser::new(24, 80, 100);
parser2.process(b"fresh output");
{
let mut shared = pty.shared_parser.lock().unwrap();
*shared = parser2;
}
pty.dirty.store(true, Ordering::Release);
pty.screen();
assert_eq!(
pty.scrollback(),
0,
"new screen data must reset scrollback to its value"
);
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn set_scrollback_and_scrollback_consistent() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
pty.screen();
let mut lines = Vec::new();
for i in 0..30 {
writeln!(lines, "line {}", i).unwrap();
}
let mut parser = vt100::Parser::new(24, 80, 100);
parser.process(&lines);
{
let mut shared = pty.shared_parser.lock().unwrap();
*shared = parser;
}
pty.dirty.store(true, Ordering::Release);
pty.screen();
assert!(
pty.max_scrollback() >= 3,
"need enough scrollback for this test"
);
pty.set_scrollback(3);
assert_eq!(
pty.scrollback(),
3,
"scrollback() must see mutation made via set_scrollback"
);
pty.set_scrollback(5);
assert_eq!(
pty.scrollback(),
5,
"scrollback() must see mutation made via set_scrollback"
);
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn into_parts_takes_child_and_reader() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
assert!(
pty.reader_is_alive(),
"reader should be alive before into_parts"
);
let parts = pty.into_parts();
assert!(parts.child.is_some(), "child should be taken");
assert!(
parts.reader_handle.is_some(),
"reader handle should be taken"
);
assert!(
!pty.reader_is_alive(),
"reader should be dead after into_parts"
);
assert!(pty.child.is_none(), "child should be None after into_parts");
}
#[test]
fn set_status_callback_with_existing_reader_does_not_panic() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn_with_scrollback");
pty.set_status_callback(Some(Box::new(|_| {})));
pty.set_status_callback(None);
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn wrap_err_with_string() {
let e = wrap_err("openpty", "permission denied");
let s = format!("{}", e);
assert!(s.contains("pty openpty failed: permission denied"));
}
#[test]
fn wrap_err_with_io_error() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let e = wrap_err("resize", io_err);
let s = format!("{}", e);
assert!(s.contains("pty resize failed"));
assert!(s.contains("file not found"));
}
#[test]
fn wrap_err_with_integer() {
let e = wrap_err("spawn_command", 42);
let s = format!("{}", e);
assert!(s.contains("pty spawn_command failed: 42"));
}
#[test]
fn take_pending_title_clones_foreground_not_consumes() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn");
*pty.foreground_title.lock().unwrap() = Some("vim".to_string());
assert_eq!(pty.take_pending_title(), Some("vim".to_string()));
assert_eq!(pty.take_pending_title(), Some("vim".to_string()));
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn take_pending_title_purges_stale_osc_when_fg_present() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn");
*pty.foreground_title.lock().unwrap() = Some("vim".to_string());
*pty.pending_title.lock().unwrap() = Some("user@host".to_string());
assert_eq!(pty.take_pending_title(), Some("vim".to_string()));
assert_eq!(
*pty.pending_title.lock().unwrap(),
None,
"stale OSC title must be purged"
);
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn take_pending_title_falls_back_to_osc_when_no_fg() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn");
*pty.foreground_title.lock().unwrap() = None;
*pty.pending_title.lock().unwrap() = Some("user@host".to_string());
assert_eq!(pty.take_pending_title(), Some("user@host".to_string()));
assert_eq!(
*pty.pending_title.lock().unwrap(),
None,
"OSC title must be consumed"
);
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn take_pending_title_returns_none_when_both_empty() {
let cmd = CommandBuilder::new(get_test_executable());
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 100).expect("spawn");
*pty.foreground_title.lock().unwrap() = None;
*pty.pending_title.lock().unwrap() = None;
assert_eq!(pty.take_pending_title(), None);
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
}
#[test]
fn history_replay_corrupts_percent_lines_via_ansi_cursor_movements() {
let mut history = Vec::new();
for i in 0..5 {
writeln!(history, "\x1b[{};1Hline {}: {} %", i + 1, i, "x".repeat(52)).unwrap();
}
let mut old = vt100::Parser::new(24, 30, 100);
old.process(&history);
let old_text = old.screen().contents();
let mut new = vt100::Parser::new(24, 80, 100);
new.process(&history);
new.screen_mut().set_size(24, 30);
let new_text = new.screen().contents();
assert_eq!(
old_text.lines().count(),
1,
"history-replay must collapse all lines into one row"
);
assert_eq!(
new_text.lines().count(),
5,
"set_size must preserve 5 separate rows"
);
}
#[test]
fn test_exited_callback_atomic_latch_under_contention() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
let call_count = Arc::new(AtomicUsize::new(0));
let count_clone = Arc::clone(&call_count);
let cb: StatusCallback = Arc::new(std::sync::Mutex::new(Some(Box::new(move |status| {
if matches!(status, crate::PtyStatus::Exited) {
count_clone.fetch_add(1, Ordering::SeqCst);
}
}))));
let exited_emitted = Arc::new(AtomicBool::new(false));
let handles: Vec<_> = (0..10)
.map(|_| {
let cb = Arc::clone(&cb);
let emitted = Arc::clone(&exited_emitted);
thread::spawn(move || {
if !emitted.swap(true, Ordering::AcqRel)
&& let Ok(guard) = cb.lock()
&& let Some(ref f) = *guard
{
f(crate::PtyStatus::Exited);
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"Exited callback must execute exactly once under thread contention"
);
}
#[test]
fn cursor_bounded_shrink_preserves_bottom_when_cursor_at_bottom() {
let mut parser = vt100::Parser::new(30, 80, 200);
for i in 0..30 {
parser.process(format!("line {}\r\n", i).as_bytes());
}
parser.process(b"LASTLINE");
let (cursor_row, _) = parser.screen().cursor_position();
let new_rows: u16 = 24;
assert_eq!(cursor_row, 29, "cursor at bottom");
let scroll_lines = cursor_row - new_rows + 1;
assert_eq!(scroll_lines, 6);
parser.process(format!("\x1b[{}S", scroll_lines).as_bytes());
parser.screen_mut().set_size(new_rows, 80);
assert!(
parser.screen().contents().contains("LASTLINE"),
"bottom content preserved when cursor at bottom"
);
assert_eq!(parser.screen().size(), (24, 80));
}
#[test]
fn cursor_bounded_shrink_skips_when_cursor_above_new_height() {
let mut parser = vt100::Parser::new(30, 80, 200);
for i in 0..10 {
parser.process(format!("line {}\r\n", i).as_bytes());
}
parser.process(b"MIDLINE");
let (cursor_row, _) = parser.screen().cursor_position();
assert!(
cursor_row < 24,
"cursor ({cursor_row}) above new viewport height"
);
parser.screen_mut().set_size(24, 80);
assert!(
parser.screen().contents().contains("MIDLINE"),
"content preserved without any SU shift"
);
assert_eq!(parser.screen().size(), (24, 80));
}
}