use std::fs::File;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
pub(crate) struct PtyCapture {
master: File,
tty: File,
saved_err: Option<RawFd>,
}
impl PtyCapture {
pub(crate) fn install(cols: u16, rows: u16) -> io::Result<Self> {
unsafe {
let mut master: libc::c_int = -1;
let mut slave: libc::c_int = -1;
let mut ws = winsize(cols, rows);
if libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut::<libc::termios>(),
std::ptr::addr_of_mut!(ws),
) != 0
{
return Err(io::Error::last_os_error());
}
let mut tio: libc::termios = std::mem::zeroed();
if libc::tcgetattr(slave, &mut tio) == 0 {
tio.c_oflag &= !libc::OPOST;
tio.c_lflag &= !libc::ECHO;
let _ = libc::tcsetattr(slave, libc::TCSANOW, &tio);
}
let capture_err = libc::isatty(2) == 1;
let saved_out = libc::dup(1);
let saved_err = if capture_err { libc::dup(2) } else { -1 };
if saved_out < 0 || (capture_err && saved_err < 0) {
let err = io::Error::last_os_error();
libc::close(master);
libc::close(slave);
if saved_out >= 0 {
libc::close(saved_out);
}
if saved_err >= 0 {
libc::close(saved_err);
}
return Err(err);
}
libc::fcntl(saved_out, libc::F_SETFD, libc::FD_CLOEXEC);
if capture_err {
libc::fcntl(saved_err, libc::F_SETFD, libc::FD_CLOEXEC);
}
libc::fcntl(master, libc::F_SETFD, libc::FD_CLOEXEC);
if libc::dup2(slave, 1) < 0 {
let err = io::Error::last_os_error();
libc::dup2(saved_out, 1);
libc::close(master);
libc::close(slave);
libc::close(saved_out);
if saved_err >= 0 {
libc::close(saved_err);
}
return Err(err);
}
if capture_err && libc::dup2(slave, 2) < 0 {
let err = io::Error::last_os_error();
libc::dup2(saved_out, 1);
libc::dup2(saved_err, 2);
libc::close(master);
libc::close(slave);
libc::close(saved_out);
libc::close(saved_err);
return Err(err);
}
libc::close(slave);
Ok(Self {
master: File::from_raw_fd(master),
tty: File::from_raw_fd(saved_out),
saved_err: capture_err.then_some(saved_err),
})
}
}
pub(crate) fn tty(&self) -> &File {
&self.tty
}
pub(crate) fn foreground_stdio(&self, command: &mut std::process::Command) -> io::Result<()> {
use std::os::fd::BorrowedFd;
command.stdout(self.tty.try_clone()?);
if let Some(fd) = self.saved_err {
let error = unsafe { BorrowedFd::borrow_raw(fd) }.try_clone_to_owned()?;
command.stderr(File::from(error));
}
Ok(())
}
pub(crate) fn master_fd(&self) -> RawFd {
self.master.as_raw_fd()
}
pub(crate) fn read_available(&self, buf: &mut [u8]) -> io::Result<usize> {
let n = unsafe { libc::read(self.master.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) };
if n < 0 {
let err = io::Error::last_os_error();
return match err.kind() {
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted => Ok(0),
_ if err.raw_os_error() == Some(libc::EIO) => Ok(0),
_ => Err(err),
};
}
Ok(n as usize)
}
pub(crate) fn resize(&self, cols: u16, rows: u16) {
let ws = winsize(cols, rows);
unsafe {
let _ = libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &ws);
}
}
}
impl Drop for PtyCapture {
fn drop(&mut self) {
unsafe {
libc::dup2(self.tty.as_raw_fd(), 1);
if let Some(saved_err) = self.saved_err {
libc::dup2(saved_err, 2);
libc::close(saved_err);
}
}
}
}
fn winsize(cols: u16, rows: u16) -> libc::winsize {
libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn inode(fd: RawFd) -> u64 {
unsafe {
let mut st: libc::stat = std::mem::zeroed();
assert_eq!(libc::fstat(fd, &mut st), 0);
st.st_ino
}
}
fn write_fd(fd: RawFd, bytes: &[u8]) {
let n = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) };
assert_eq!(n as usize, bytes.len());
}
fn drain(cap: &PtyCapture) -> Vec<u8> {
let mut out = Vec::new();
let mut buf = [0u8; 4096];
for _ in 0..50 {
let mut pfd = libc::pollfd {
fd: cap.master_fd(),
events: libc::POLLIN,
revents: 0,
};
let ready = unsafe { libc::poll(&mut pfd, 1, 50) };
if ready <= 0 {
if out.is_empty() {
continue;
}
break;
}
let n = cap.read_available(&mut buf).unwrap();
out.extend_from_slice(&buf[..n]);
}
out
}
struct RedirectFd {
fd: RawFd,
saved: RawFd,
}
impl RedirectFd {
fn to(fd: RawFd, target: RawFd) -> Self {
unsafe {
let saved = libc::dup(fd);
assert!(saved >= 0, "dup of fd {fd}");
assert!(libc::dup2(target, fd) >= 0, "dup2 onto fd {fd}");
Self { fd, saved }
}
}
}
impl Drop for RedirectFd {
fn drop(&mut self) {
unsafe {
libc::dup2(self.saved, self.fd);
libc::close(self.saved);
}
}
}
struct TestPty {
master: RawFd,
slave: RawFd,
}
impl TestPty {
fn open() -> Self {
unsafe {
let (mut master, mut slave) = (-1, -1);
assert_eq!(
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut::<libc::termios>(),
std::ptr::null_mut::<libc::winsize>(),
),
0,
"openpty for the test's own terminal"
);
Self { master, slave }
}
}
}
impl Drop for TestPty {
fn drop(&mut self) {
unsafe {
libc::close(self.master);
libc::close(self.slave);
}
}
}
#[serial_test::serial(tty_arbiter)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn install_captures_fd1_and_a_terminal_fd2_verbatim_and_drop_restores_them() {
let err_pty = TestPty::open();
let _err = RedirectFd::to(2, err_pty.slave);
assert_eq!(
unsafe { libc::isatty(2) },
1,
"precondition: stderr is a tty"
);
let before_out = inode(1);
let before_err = inode(2);
{
let cap = PtyCapture::install(80, 24).expect("openpty");
assert_ne!(inode(1), before_out, "fd 1 was swung onto the pty");
assert_eq!(inode(1), inode(2), "fd 1 and fd 2 share the slave");
assert_eq!(unsafe { libc::isatty(1) }, 1, "the slave IS a terminal");
write_fd(1, b"out\n");
write_fd(2, b"\x1b[31merr\x1b[0m\n");
let got = drain(&cap);
let got = String::from_utf8_lossy(&got);
assert!(got.contains("out\n"), "verbatim, no \\r\\n: {got:?}");
assert!(got.contains("\x1b[31merr\x1b[0m\n"), "stderr too: {got:?}");
}
assert_eq!(inode(1), before_out, "fd 1 restored on drop");
assert_eq!(inode(2), before_err, "fd 2 restored on drop");
}
#[serial_test::serial(tty_arbiter)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn install_leaves_a_redirected_stderr_untouched() {
let mut fds = [0 as libc::c_int; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe()");
let (pipe_r, pipe_w) = (fds[0], fds[1]);
let _err = RedirectFd::to(2, pipe_w);
assert_eq!(
unsafe { libc::isatty(2) },
0,
"precondition: stderr is a pipe"
);
let before_err = inode(2);
{
let cap = PtyCapture::install(80, 24).expect("openpty");
assert_eq!(unsafe { libc::isatty(1) }, 1, "fd 1 is still captured");
assert_eq!(
inode(2),
before_err,
"the redirected stderr was left in place"
);
assert_ne!(
inode(1),
inode(2),
"fd 2 did not join fd 1 on the pty slave"
);
write_fd(2, b"to the file\n");
let mut buf = [0u8; 64];
let n = unsafe { libc::read(pipe_r, buf.as_mut_ptr().cast(), buf.len()) };
assert!(n > 0, "the stderr byte should have reached the pipe");
assert_eq!(&buf[..n as usize], b"to the file\n", "verbatim to the pipe");
write_fd(1, b"stdout-marker\n");
let got = drain(&cap);
let got = String::from_utf8_lossy(&got);
assert!(
got.contains("stdout-marker"),
"stdout still captured: {got:?}"
);
assert!(
!got.contains("to the file"),
"redirected stderr must not leak to the pty: {got:?}"
);
}
assert_eq!(
inode(2),
before_err,
"fd 2 never moved — nothing to restore"
);
unsafe {
libc::close(pipe_r);
libc::close(pipe_w);
}
}
#[serial_test::serial(tty_arbiter)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn the_slave_reports_the_size_it_was_given_and_follows_resize() {
let cap = PtyCapture::install(100, 30).expect("openpty");
let size = |fd: RawFd| {
unsafe {
let mut ws: libc::winsize = std::mem::zeroed();
assert_eq!(libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws), 0);
(ws.ws_col, ws.ws_row)
}
};
assert_eq!(size(1), (100, 30));
cap.resize(120, 40);
assert_eq!(size(1), (120, 40), "a child sizing by fd 1 sees the resize");
}
#[serial_test::serial(tty_arbiter)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn repeated_enter_and_leave_cycles_restore_every_time_and_leak_nothing() {
let err_pty = TestPty::open();
let _err = RedirectFd::to(2, err_pty.slave);
let before_out = inode(1);
let before_err = inode(2);
for cycle in 0..8 {
{
let cap = PtyCapture::install(80, 24).expect("openpty");
assert_ne!(inode(1), before_out, "cycle {cycle}: fd 1 took the pty");
write_fd(1, b"tick\n");
let got = drain(&cap);
assert!(
String::from_utf8_lossy(&got).contains("tick"),
"cycle {cycle}: the capture still carries output"
);
}
assert_eq!(inode(1), before_out, "cycle {cycle}: fd 1 restored");
assert_eq!(inode(2), before_err, "cycle {cycle}: fd 2 restored");
}
}
#[serial_test::serial(tty_arbiter)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_panic_while_the_cockpit_owns_the_terminal_still_restores_it() {
let err_pty = TestPty::open();
let _err = RedirectFd::to(2, err_pty.slave);
let before_out = inode(1);
let before_err = inode(2);
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); let result = std::panic::catch_unwind(|| {
let _cap = PtyCapture::install(80, 24).expect("openpty");
assert_ne!(inode(1), before_out, "precondition: the pty is installed");
panic!("turn exploded while the cockpit held the terminal");
});
std::panic::set_hook(hook);
assert!(
result.is_err(),
"the panic must propagate, not be swallowed"
);
assert_eq!(inode(1), before_out, "fd 1 restored through the unwind");
assert_eq!(inode(2), before_err, "fd 2 restored through the unwind");
}
#[serial_test::serial(tty_arbiter)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_burst_larger_than_the_pty_buffer_does_not_deadlock_the_writer() {
let cap = PtyCapture::install(80, 24).expect("openpty");
let master = cap.master_fd();
const CHUNK: usize = 4096;
const CHUNKS: usize = 128;
let want = CHUNK * CHUNKS;
let reader = std::thread::spawn(move || {
let mut seen = 0usize;
let mut buf = [0u8; 8192];
while seen < want {
let n = unsafe { libc::read(master, buf.as_mut_ptr().cast(), buf.len()) };
if n <= 0 {
break;
}
seen += n as usize;
}
seen
});
let line = vec![b'x'; CHUNK];
for _ in 0..CHUNKS {
write_fd(1, &line);
}
let seen = reader.join().expect("drainer thread");
assert!(
seen >= want,
"the drainer saw {seen} of {want} bytes — the writer starved or the pty dropped output"
);
drop(cap);
}
}