use std::io::{Read, Write};
use std::sync::{
Arc, Condvar, Mutex, OnceLock,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::thread::{self, JoinHandle};
use std::time::Instant;
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
#[cfg(windows)]
use crate::job_object::JobObject;
use crate::pty_state_tracker::PtyPerformAdapter;
use term_clipboard::{Clipboard, Osc52Extractor};
const PTY_READ_BUF_SIZE: usize = 65536;
#[cfg(unix)]
const MAX_DRAIN_BYTES: usize = 65536;
const ERROR_OPERATION_ABORTED: i32 = 995;
#[cfg(unix)]
use std::os::unix::io::RawFd;
#[cfg(unix)]
struct ResizeWake {
read_fd: RawFd,
write_fd: RawFd,
}
#[cfg(unix)]
impl ResizeWake {
fn new() -> std::io::Result<Self> {
let mut fds = [0i32; 2];
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
return Err(std::io::Error::last_os_error());
}
for fd in &fds {
unsafe { libc::fcntl(*fd, libc::F_SETFL, libc::O_NONBLOCK) };
}
Ok(Self {
read_fd: fds[0],
write_fd: fds[1],
})
}
fn read_fd(&self) -> RawFd {
self.read_fd
}
fn signal(&self) {
let byte = [1u8];
unsafe {
let _ = libc::write(self.write_fd, byte.as_ptr() as *const libc::c_void, 1);
}
}
}
#[cfg(unix)]
impl Drop for ResizeWake {
fn drop(&mut self) {
unsafe {
libc::close(self.read_fd);
libc::close(self.write_fd);
}
}
}
#[cfg(unix)]
fn clear_wake(fd: RawFd) {
let mut buf = [0u8; 64];
loop {
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n <= 0 {
break;
}
}
}
const HISTORY_TAIL_LEN: usize = 8;
const DSR_PATTERN_LEN: usize = 4;
const ESC_TRACE_ENV: &str = "TERM_WM_TRACE_ESC";
static ESC_TRACE_ENABLED: OnceLock<bool> = OnceLock::new();
fn esc_trace_chunk(bytes: &[u8]) {
if !*ESC_TRACE_ENABLED.get_or_init(|| std::env::var_os(ESC_TRACE_ENV).is_some()) {
return;
}
let path = std::env::var(ESC_TRACE_ENV).unwrap_or_else(|_| "term_wm_esc_trace.log".to_string());
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(file, "{}", hex_bytes(bytes));
}
}
fn hex_bytes(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
#[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 {
#[cfg(unix)]
master: Arc<Mutex<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<term_wm_vt100::Parser>>,
pub(crate) dirty: Arc<AtomicBool>,
pub(crate) dirty_cond: Arc<(Mutex<()>, Condvar)>,
size: Arc<Mutex<PtySize>>,
pending_resize: Arc<Mutex<Option<PtySize>>>,
#[cfg(unix)]
resize_wake: ResizeWake,
scrollback_len: usize,
child: Option<Box<dyn Child + Send + Sync>>,
#[cfg(windows)]
job: Option<JobObject>,
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<()>>,
}
#[cfg(target_os = "macos")]
const CHILD_TERM: &str = "xterm-256color";
#[cfg(not(target_os = "macos"))]
const CHILD_TERM: &str = "screen-256color";
const CHILD_COLORTERM: &str = "truecolor";
fn sanitize_child_environment(command: &mut CommandBuilder) {
sanitize_child_environment_with_lc_ctype(command, std::env::var("LC_CTYPE").ok().as_deref());
}
fn sanitize_child_environment_with_lc_ctype(command: &mut CommandBuilder, lc_ctype: Option<&str>) {
command.env("TERM", CHILD_TERM);
command.env("COLORTERM", CHILD_COLORTERM);
if lc_ctype == Some("UTF-8") {
command.env_remove("LC_CTYPE");
}
}
impl Pty {
pub fn spawn(command: CommandBuilder, size: PtySize) -> PtyResult<Self> {
Self::spawn_with_scrollback(command, size, 0)
}
pub fn spawn_with_scrollback(
mut command: CommandBuilder,
size: PtySize,
scrollback_len: usize,
) -> PtyResult<Self> {
sanitize_child_environment(&mut command);
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))?;
#[cfg(windows)]
let job = {
let job = JobObject::new().ok();
if let (Some(job), Some(proc)) = (&job, child.as_raw_handle())
&& let Err(err) = unsafe { job.assign(proc) }
{
tracing::warn!(
"AssignProcessToJobObject failed: {err}; falling back to single-process kill"
);
}
job
};
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 master = Arc::new(Mutex::new(pair.master));
let reader_master = Arc::clone(&master);
let size_arc = Arc::new(Mutex::new(size));
let reader_size = Arc::clone(&size_arc);
let pending_resize = Arc::new(Mutex::new(None));
let reader_pending_resize = Arc::clone(&pending_resize);
#[cfg(unix)]
let resize_wake = ResizeWake::new().map_err(|err| wrap_err("resize wake", 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 = term_wm_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_shutdown = Arc::clone(&shutdown);
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);
#[cfg(unix)]
let wake_read_fd = resize_wake.read_fd();
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,
master: reader_master,
size: reader_size,
pending_resize: reader_pending_resize,
#[cfg(unix)]
wake_read_fd,
shutdown: reader_shutdown,
})
});
Ok(Self {
#[cfg(unix)]
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,
size: size_arc,
pending_resize,
#[cfg(unix)]
resize_wake,
tracker,
scrollback_len,
child: Some(child),
#[cfg(windows)]
job,
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();
}
self.wake_reader();
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(());
}
let mut pending = self
.pending_resize
.lock()
.unwrap_or_else(|err| err.into_inner());
if *pending == Some(size) {
return Ok(()); }
*pending = Some(size);
drop(pending);
self.wake_reader();
Ok(())
}
fn wake_reader(&mut self) {
#[cfg(unix)]
self.resize_wake.signal();
#[cfg(windows)]
if let Some(reader) = &self.reader {
use std::os::windows::io::{AsHandle, AsRawHandle};
unsafe {
kernel32::CancelSynchronousIo(reader.as_handle().as_raw_handle());
}
}
}
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
.lock()
.unwrap_or_else(|err| err.into_inner())
.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 drain_final_output(&mut self, grace: std::time::Duration) -> Vec<u8> {
self.screen();
if let Some(handle) = self.reader.as_ref() {
let deadline = std::time::Instant::now() + grace;
while !handle.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
self.drain_pending()
}
pub fn screen_lines(&mut self) -> Vec<String> {
self.screen(); let screen = self
.shared_parser
.lock()
.unwrap_or_else(|err| err.into_inner())
.screen()
.clone();
let contents = screen.contents();
let mut lines: Vec<String> = contents.lines().map(|line| line.to_string()).collect();
let rows = self.size.lock().unwrap_or_else(|err| err.into_inner()).rows as usize;
if lines.len() < rows {
lines.resize(rows, 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<()> {
#[cfg(windows)]
if let Some(job) = self.job.take() {
let _ = job.terminate(1);
}
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
.lock()
.unwrap_or_else(|err| err.into_inner())
.process_group_leader()
}
#[cfg(unix)]
pub fn signal_process_group(&self, signal: i32) -> PtyResult<()> {
let Some(pgid) = self
.master
.lock()
.unwrap_or_else(|err| err.into_inner())
.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.lock().unwrap_or_else(|err| err.into_inner())
}
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();
self.shared_parser
.lock()
.unwrap_or_else(|err| err.into_inner())
.screen()
.clone()
.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();
}
self.wake_reader();
}
}
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<term_wm_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>,
master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
size: Arc<Mutex<PtySize>>,
pending_resize: Arc<Mutex<Option<PtySize>>>,
#[cfg(unix)]
wake_read_fd: RawFd,
shutdown: Arc<AtomicBool>,
}
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,
master,
size,
pending_resize,
#[cfg(unix)]
wake_read_fd,
shutdown,
} = 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: Option<Clipboard> = clipboard;
const IO_BURST_BUDGET: usize = 256 * 1024; #[cfg(unix)]
let master_fd = master
.lock()
.unwrap_or_else(|err| err.into_inner())
.as_raw_fd()
.unwrap_or(-1);
'reader: loop {
if shutdown.load(Ordering::Acquire) {
break 'reader;
}
#[cfg(unix)]
{
let mut pollfds = [
libc::pollfd {
fd: master_fd,
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: wake_read_fd,
events: libc::POLLIN,
revents: 0,
},
];
let _ = unsafe { libc::poll(pollfds.as_mut_ptr(), pollfds.len() as libc::nfds_t, -1) };
if pollfds[1].revents & libc::POLLIN != 0 {
clear_wake(wake_read_fd);
}
}
#[cfg(unix)]
let mut drain_bytes = 0usize;
#[allow(clippy::never_loop)]
loop {
#[cfg(windows)]
{
let has_pending = pending_resize
.lock()
.unwrap_or_else(|err| err.into_inner())
.is_some();
if has_pending || shutdown.load(Ordering::Acquire) {
break;
}
}
#[cfg(unix)]
{
let mut pfd = libc::pollfd {
fd: master_fd,
events: libc::POLLIN,
revents: 0,
};
let pr = unsafe { libc::poll(&mut pfd, 1, 0) };
let hup = pfd.revents & (libc::POLLHUP | libc::POLLERR) != 0;
if pr <= 0 || (pfd.revents & libc::POLLIN == 0 && !hup) {
break;
}
}
match reader.read(&mut buf) {
Ok(0) => {
if let Some(text) = osc52.finish() {
if clipboard.is_none() {
clipboard = Some(Clipboard::new());
}
if let Some(clip) = clipboard.as_mut() {
clip.set(&text);
}
if let Some(ref capture) = osc52_text {
*capture.lock().unwrap_or_else(|err| err.into_inner()) = Some(text);
}
}
let guard = status_cb.lock().unwrap_or_else(|err| err.into_inner());
if let Some(ref cb) = *guard {
cb(crate::PtyStatus::Wakeup);
if !exited_emitted.swap(true, Ordering::AcqRel) {
cb(crate::PtyStatus::Exited);
}
}
break 'reader;
}
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);
}
let mut last = last_bytes.lock().unwrap_or_else(|err| err.into_inner());
last.clear();
last.extend_from_slice(&buf[..n]);
let mut p = pending.lock().unwrap_or_else(|err| err.into_inner());
p.extend_from_slice(&buf[..n]);
const PENDING_CAP: usize = 1024 * 1024; if p.len() > PENDING_CAP {
p.clear();
}
let prev_mode = tracker.direct_input_mode();
vte_parser.advance(&mut tracker_adapter, &buf[..n]);
let new_mode = tracker.direct_input_mode();
if prev_mode != new_mode {
tracing::info!(
"[STAGE 1] PTY routing flipped: {:?} -> {:?}",
prev_mode,
new_mode
);
let guard = status_cb.lock().unwrap_or_else(|err| err.into_inner());
if let Some(ref cb) = *guard {
cb(crate::PtyStatus::DirectInputChanged(new_mode));
} else {
tracing::error!(
"[STAGE 1] status_cb is NONE when transition occurred!"
);
}
}
{
let mut shared =
shared_parser.lock().unwrap_or_else(|err| err.into_inner());
shared.process(&buf[..n]);
}
esc_trace_chunk(&buf[..n]);
if let Some(title) = extract_osc_title(&buf[..n]) {
let mut guard = pending_title.lock().unwrap_or_else(|err| err.into_inner());
*guard = Some(title);
}
if let Some(text) = osc52.push(&buf[..n], &prev_tail) {
if clipboard.is_none() {
clipboard = Some(Clipboard::new());
}
if let Some(clip) = clipboard.as_mut() {
clip.set(&text);
}
if let Some(ref capture) = osc52_text {
*capture.lock().unwrap_or_else(|err| err.into_inner()) = 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) {
let guard = status_cb.lock().unwrap_or_else(|err| err.into_inner());
if 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_or_else(|err| err.into_inner());
while dirty.load(Ordering::Acquire) {
guard = cvar.wait(guard).unwrap_or_else(|err| err.into_inner());
}
bytes_since_render = 0;
}
#[cfg(unix)]
{
drain_bytes += n;
if drain_bytes >= MAX_DRAIN_BYTES {
break;
}
}
}
Err(err) => {
if err.raw_os_error() == Some(ERROR_OPERATION_ABORTED) {
break;
}
let guard = status_cb.lock().unwrap_or_else(|err| err.into_inner());
if let Some(ref cb) = *guard
&& !exited_emitted.swap(true, Ordering::AcqRel)
{
cb(crate::PtyStatus::Exited);
}
break 'reader;
}
}
#[cfg(windows)]
{
break;
}
}
let new_size = pending_resize
.lock()
.unwrap_or_else(|err| err.into_inner())
.take();
if let Some(new_size) = new_size {
apply_resize(new_size, &shared_parser, &tracker, &master, &size);
}
}
}
fn apply_resize(
new_size: PtySize,
shared_parser: &Arc<Mutex<term_wm_vt100::Parser>>,
tracker: &Arc<crate::PtyStateTracker>,
master: &Arc<Mutex<Box<dyn MasterPty + Send>>>,
size: &Arc<Mutex<PtySize>>,
) {
let old_rows = {
let parser = shared_parser.lock().unwrap_or_else(|err| err.into_inner());
parser.screen().size().0
};
let mut guard = shared_parser.lock().unwrap_or_else(|err| err.into_inner());
if new_size.rows < old_rows && !tracker.has_custom_margins() {
let (cursor_row, _) = guard.screen().cursor_position();
if cursor_row >= new_size.rows {
let scroll_lines = cursor_row - new_size.rows + 1;
let seq = format!("\x1b[{scroll_lines}S");
guard.process(seq.as_bytes());
}
}
guard.screen_mut().set_size(new_size.rows, new_size.cols);
drop(guard);
tracker.resize(new_size.rows);
*size.lock().unwrap_or_else(|err| err.into_inner()) = new_size;
if let Err(e) = master
.lock()
.unwrap_or_else(|err| err.into_inner())
.resize(new_size)
{
tracing::warn!("drain-sync PTY resize failed: {e}");
}
}
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;
pub fn CancelSynchronousIo(hThread: *mut std::ffi::c_void) -> 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;
#[cfg(windows)]
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"
}
}
#[cfg(windows)]
fn get_mock_bin() -> std::path::PathBuf {
term_session_mock::get_mock_bin()
}
#[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(payload: &[u8]) -> ParserReadLoopArgs {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty");
#[cfg(unix)]
let reader = {
let mut escaped = String::from("printf '");
for &b in payload {
match b {
b'\'' => escaped.push_str("'\\''"),
0x1b => escaped.push_str("\\033"),
b'\n' => escaped.push_str("\\n"),
b'\r' => escaped.push_str("\\r"),
b'\t' => escaped.push_str("\\t"),
0x20..=0x7e => escaped.push(b as char),
_ => escaped.push_str(&format!("\\{:03o}", b)),
}
}
escaped.push('\'');
let mut builder = CommandBuilder::new("sh");
builder.arg("-c");
builder.arg(escaped);
let _child = pair
.slave
.spawn_command(builder)
.expect("spawn printf child");
pair.master.try_clone_reader().expect("reader")
};
#[cfg(windows)]
let reader = Box::new(Cursor::new(payload.to_vec()));
let master = Arc::new(Mutex::new(pair.master));
drop(pair.slave);
#[cfg(unix)]
let wake_read_fd = {
let wake = Box::leak(Box::new(ResizeWake::new().expect("resize wake")));
wake.read_fd()
};
ParserReadLoopArgs {
reader,
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(term_wm_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)),
master,
size: Arc::new(Mutex::new(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})),
pending_resize: Arc::new(Mutex::new(None)),
#[cfg(unix)]
wake_read_fd,
shutdown: Arc::new(AtomicBool::new(false)),
}
}
#[test]
fn parser_read_loop_reads_and_sets_pending_and_last() {
let payload = b"hello\r\n\x1b[6nworld";
let args = make_parser_test_args(payload);
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 args = make_parser_test_args(b"");
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_decawn_off_does_not_wrap() {
let mut payload = Vec::new();
payload.extend_from_slice(b"\x1b[?7l");
payload.extend_from_slice(&[b'x'; 85]);
let args = make_parser_test_args(&payload);
let shared = Arc::clone(&args.shared_parser);
parser_read_loop(args);
let parser = shared.lock().unwrap();
let screen = parser.screen();
assert_eq!(
screen.cursor_position(),
(0, 79),
"DECAWM off must clamp the cursor to the right margin"
);
assert_eq!(
screen.cell(0, 79).unwrap().contents(),
"x",
"the 85th char must overwrite the margin cell, not wrap"
);
assert!(
(0..80).all(|col| screen
.cell(1, col)
.is_none_or(|cell| cell.contents().is_empty())),
"row 1 must remain empty (no wrap)"
);
}
#[test]
fn parser_read_loop_insert_mode_inserts() {
let args = make_parser_test_args(b"hello\x1b[1;2H\x1b[4hX\x1b[4l");
let shared = Arc::clone(&args.shared_parser);
parser_read_loop(args);
let parser = shared.lock().unwrap();
let screen = parser.screen();
assert_eq!(screen.cell(0, 1).unwrap().contents(), "X", "inserted char");
assert_eq!(
screen.cell(0, 2).unwrap().contents(),
"e",
"existing text must shift right, not be overwritten"
);
assert_eq!(
screen.cell(0, 5).unwrap().contents(),
"o",
"row tail preserved"
);
}
#[test]
fn parser_read_loop_status_callback_called_when_set() {
let args = make_parser_test_args(b"data");
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 args = make_parser_test_args(b"XX\x1b[6nYY");
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(&term_clipboard::format_osc52_bytes("clip via pty"));
args.clipboard = Some(Clipboard::with_shared_buffer(Arc::clone(&shared)));
let captured = Arc::new(Mutex::new(None));
args.osc52_text = Some(Arc::clone(&captured));
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]
#[cfg(windows)]
fn spawn_assigns_job_object_containing_child() {
let mock = get_mock_bin();
let mut cmd = CommandBuilder::new(mock);
cmd.arg("spawn_child");
cmd.arg("60000");
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.job.is_some(),
"spawned child must be contained in a Job Object on Windows"
);
let mut accumulated = Vec::new();
let start = std::time::Instant::now();
let grandchild = loop {
pty.screen();
accumulated.extend_from_slice(&pty.drain_pending());
if let Some(idx) = accumulated
.windows(b"GRANDCHILD_PID:".len())
.position(|w| w == b"GRANDCHILD_PID:")
{
let rest = &accumulated[idx + b"GRANDCHILD_PID:".len()..];
let pid_str: String = rest
.iter()
.take_while(|b| b.is_ascii_digit())
.map(|b| *b as char)
.collect();
break pid_str
.parse()
.unwrap_or_else(|_| panic!("invalid grandchild PID in {rest:?}"));
}
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"mock spawn_child should print a grandchild PID; got {accumulated:?}"
);
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert!(
term_session_mock::process_is_alive(grandchild),
"grandchild {grandchild} should be alive before kill_child"
);
pty.kill_child().expect("kill_child");
assert!(pty.job.is_none(), "job must be taken on kill_child");
assert!(pty.child.is_none(), "child must be reaped on kill_child");
assert!(pty.exited, "pty must be marked exited after kill_child");
let start = std::time::Instant::now();
while term_session_mock::process_is_alive(grandchild) {
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"grandchild {grandchild} should be dead after kill_child"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
#[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 = term_wm_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 = term_wm_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 = term_wm_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 = term_wm_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 = term_wm_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 = term_wm_vt100::Parser::new(24, 30, 100);
old.process(&history);
let old_text = old.screen().contents();
let mut new = term_wm_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 all 5 logical lines (via reflow)"
);
}
#[test]
fn reflow_preserves_scrollback_content_on_width_shrink() {
let mut parser = term_wm_vt100::Parser::new(24, 80, 2000);
let long_line = "x".repeat(200);
for i in 0..40 {
parser.process(format!("line {i}: {}\r\n", long_line).as_bytes());
}
let count_chars =
|parser: &mut term_wm_vt100::Parser| -> std::collections::BTreeMap<char, usize> {
let (rows, cols) = parser.screen().size();
parser.screen_mut().set_scrollback(usize::MAX);
let sb = parser.screen().scrollback();
let mut map = std::collections::BTreeMap::new();
for i in 0..sb {
parser.screen_mut().set_scrollback(sb - i);
for c in 0..cols {
if let Some(cell) = parser.screen().cell(0, c) {
for ch in cell.contents().chars() {
*map.entry(ch).or_insert(0) += 1;
}
}
}
}
parser.screen_mut().set_scrollback(0);
for r in 0..rows {
for c in 0..cols {
if let Some(cell) = parser.screen().cell(r, c) {
for ch in cell.contents().chars() {
*map.entry(ch).or_insert(0) += 1;
}
}
}
}
parser.screen_mut().set_scrollback(0);
map
};
let before = count_chars(&mut parser);
parser.screen_mut().set_size(24, 40);
let after = count_chars(&mut parser);
assert_eq!(
before, after,
"width shrink must not lose scrollback content"
);
}
#[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 = term_wm_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 = term_wm_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));
}
fn fresh_cmd() -> CommandBuilder {
CommandBuilder::new("true")
}
#[test]
fn sanitize_sets_multiplexer_safe_term() {
let mut cmd = fresh_cmd();
sanitize_child_environment_with_lc_ctype(&mut cmd, None);
assert_eq!(
cmd.get_env("TERM").and_then(|v| v.to_str()),
Some(expected_child_term())
);
}
#[test]
fn sanitize_sets_truecolor() {
let mut cmd = fresh_cmd();
sanitize_child_environment_with_lc_ctype(&mut cmd, None);
assert_eq!(
cmd.get_env("COLORTERM").and_then(|v| v.to_str()),
Some("truecolor")
);
}
#[test]
fn sanitize_strips_invalid_lc_ctype_utf8() {
let mut cmd = fresh_cmd();
cmd.env("LC_CTYPE", "UTF-8");
sanitize_child_environment_with_lc_ctype(&mut cmd, Some("UTF-8"));
assert!(
cmd.get_env("LC_CTYPE").is_none(),
"invalid LC_CTYPE=UTF-8 must be stripped"
);
}
#[test]
fn sanitize_keeps_valid_lc_ctype() {
let mut cmd = fresh_cmd();
cmd.env("LC_CTYPE", "en_US.UTF-8");
sanitize_child_environment_with_lc_ctype(&mut cmd, Some("en_US.UTF-8"));
assert_eq!(
cmd.get_env("LC_CTYPE").and_then(|v| v.to_str()),
Some("en_US.UTF-8"),
"valid locale must be preserved"
);
}
#[test]
#[cfg(unix)]
fn spawned_child_receives_sanitized_environment() {
let script = indoc::indoc! {r#"
TERM_VAL="${TERM:-<unset>}"
COLORTERM_VAL="${COLORTERM:-<unset>}"
LC_CTYPE_VAL="${LC_CTYPE:-<unset>}"
printf 'TERM=%s\nCOLORTERM=%s\nLC_CTYPE=%s\n' \
"$TERM_VAL" "$COLORTERM_VAL" "$LC_CTYPE_VAL"
"#};
let mut cmd = CommandBuilder::new("sh");
cmd.arg("-c");
cmd.arg(script);
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let mut pty = Pty::spawn_with_scrollback(cmd, size, 0).expect("spawn");
let mut accumulated = Vec::new();
let start = std::time::Instant::now();
loop {
pty.screen();
accumulated.extend_from_slice(&pty.drain_pending());
let out = String::from_utf8_lossy(&accumulated);
if out.contains("LC_CTYPE=") {
break;
}
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"child never printed sanitized env; got {out}"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
if let Some(child) = pty.child.as_mut() {
let _ = child.kill();
}
let out = String::from_utf8_lossy(&accumulated);
assert!(
out.contains(&format!("TERM={}", expected_child_term())),
"expected TERM={}, got {out}",
expected_child_term()
);
assert!(
out.contains("COLORTERM=truecolor"),
"expected COLORTERM=truecolor, got {out}"
);
assert!(
!out.contains("LC_CTYPE=UTF-8"),
"invalid LC_CTYPE=UTF-8 must not reach the child, got {out}"
);
}
fn expected_child_term() -> &'static str {
CHILD_TERM
}
}