use std::{
collections::VecDeque,
ffi::OsString,
io,
os::{
fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd},
unix::process::CommandExt as _,
},
path::Path,
process::Stdio,
sync::Mutex as StdMutex,
time::Duration,
};
use tokio::{process::Command, sync::Notify, time::Instant};
use crate::{process::apply_agent_environment, sync::lock};
const IO_WAIT: Duration = Duration::from_millis(500);
const READ_CHUNK: usize = 16 * 1024;
pub(crate) struct Pty {
master: OwnedFd,
wake_read: OwnedFd,
wake_write: OwnedFd,
io: StdMutex<Io>,
progressed: Notify,
}
impl std::fmt::Debug for Pty {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Pty")
.field("master", &self.master.as_raw_fd())
.finish_non_exhaustive()
}
}
#[derive(Debug, Default)]
struct Io {
queue: VecDeque<Chunk>,
offset: usize,
done: u64,
next: u64,
closed: bool,
stop: bool,
}
#[derive(Debug)]
struct Chunk {
input: Option<u64>,
bytes: Vec<u8>,
}
pub(crate) struct Spawned {
pub(crate) child: tokio::process::Child,
pub(crate) pid: u32,
pub(crate) pty: Pty,
}
pub(crate) struct PtySpec<'a> {
pub(crate) executable: &'a Path,
pub(crate) args: &'a [String],
pub(crate) cwd: &'a Path,
pub(crate) environment: &'a [(OsString, OsString)],
pub(crate) cols: u16,
pub(crate) rows: u16,
}
#[cfg(target_os = "linux")]
fn set_controlling_terminal() -> libc::Ioctl {
libc::TIOCSCTTY
}
#[cfg(not(target_os = "linux"))]
fn set_controlling_terminal() -> libc::c_ulong {
libc::c_ulong::from(libc::TIOCSCTTY)
}
pub(crate) fn spawn(spec: &PtySpec<'_>) -> io::Result<Spawned> {
let (master, slave) = open(spec.cols, spec.rows)?;
let (wake_read, wake_write) = wake_pipe()?;
for fd in [&master, &wake_read] {
if usize::try_from(fd.as_raw_fd()).is_ok_and(|fd| fd >= libc::FD_SETSIZE) {
return Err(io::Error::other(
"SCV holds too many open files to watch another terminal",
));
}
}
let request = set_controlling_terminal();
let mut command = Command::new(spec.executable);
apply_agent_environment(command.as_std_mut(), spec.environment);
command
.args(spec.args)
.current_dir(spec.cwd)
.stdin(Stdio::from(slave.try_clone()?))
.stdout(Stdio::from(slave.try_clone()?))
.stderr(Stdio::from(slave))
.kill_on_drop(true);
unsafe {
command.as_std_mut().pre_exec(move || {
if libc::setsid() < 0 {
return Err(io::Error::last_os_error());
}
if libc::ioctl(0, request, 0) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let child = command.spawn()?;
drop(command);
let pid = child
.id()
.ok_or_else(|| io::Error::other("the terminal's process has no pid"))?;
Ok(Spawned {
child,
pid,
pty: Pty {
master,
wake_read,
wake_write,
io: StdMutex::default(),
progressed: Notify::new(),
},
})
}
#[cfg(target_os = "linux")]
fn open(cols: u16, rows: u16) -> io::Result<(OwnedFd, OwnedFd)> {
let flags = libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC;
let master = unsafe { libc::open(c"/dev/ptmx".as_ptr(), flags | libc::O_NONBLOCK) };
if master < 0 {
return Err(io::Error::last_os_error());
}
let master = unsafe { OwnedFd::from_raw_fd(master) };
if unsafe { libc::grantpt(master.as_raw_fd()) } != 0 {
return Err(io::Error::last_os_error());
}
if unsafe { libc::unlockpt(master.as_raw_fd()) } != 0 {
return Err(io::Error::last_os_error());
}
let slave = open_slave(&master, flags)?;
let size = winsize(cols, rows);
if unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &raw const size) } < 0 {
return Err(io::Error::last_os_error());
}
no_flow_control(&slave)?;
Ok((master, slave))
}
#[cfg(target_os = "linux")]
fn open_slave(master: &OwnedFd, flags: libc::c_int) -> io::Result<OwnedFd> {
let slave = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCGPTPEER, flags) };
if slave < 0 {
let mut name = [0 as libc::c_char; 128];
let failed = unsafe { libc::ptsname_r(master.as_raw_fd(), name.as_mut_ptr(), name.len()) };
if failed != 0 {
return Err(io::Error::from_raw_os_error(failed));
}
let slave = unsafe { libc::open(name.as_ptr(), flags) };
if slave < 0 {
return Err(io::Error::last_os_error());
}
return Ok(unsafe { OwnedFd::from_raw_fd(slave) });
}
Ok(unsafe { OwnedFd::from_raw_fd(slave) })
}
#[cfg(not(target_os = "linux"))]
fn open(cols: u16, rows: u16) -> io::Result<(OwnedFd, OwnedFd)> {
let mut master: libc::c_int = -1;
let mut slave: libc::c_int = -1;
let mut size = winsize(cols, rows);
let opened = unsafe {
libc::openpty(
&raw mut master,
&raw mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
&raw mut size,
)
};
if opened != 0 {
return Err(io::Error::last_os_error());
}
let (master, slave) = unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) };
for fd in [&master, &slave] {
set_flag(fd, libc::F_GETFD, libc::F_SETFD, libc::FD_CLOEXEC)?;
}
set_flag(&master, libc::F_GETFL, libc::F_SETFL, libc::O_NONBLOCK)?;
no_flow_control(&slave)?;
Ok((master, slave))
}
fn wake_pipe() -> io::Result<(OwnedFd, OwnedFd)> {
let mut ends: [libc::c_int; 2] = [-1, -1];
#[cfg(target_os = "linux")]
let made = unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
#[cfg(not(target_os = "linux"))]
let made = unsafe { libc::pipe(ends.as_mut_ptr()) };
if made != 0 {
return Err(io::Error::last_os_error());
}
let (read, write) = unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
#[cfg(not(target_os = "linux"))]
for fd in [&read, &write] {
set_flag(fd, libc::F_GETFD, libc::F_SETFD, libc::FD_CLOEXEC)?;
set_flag(fd, libc::F_GETFL, libc::F_SETFL, libc::O_NONBLOCK)?;
}
Ok((read, write))
}
#[cfg(not(target_os = "linux"))]
fn set_flag(fd: &OwnedFd, get: libc::c_int, set: libc::c_int, flag: libc::c_int) -> io::Result<()> {
let flags = unsafe { libc::fcntl(fd.as_raw_fd(), get) };
if flags < 0 {
return Err(io::Error::last_os_error());
}
if unsafe { libc::fcntl(fd.as_raw_fd(), set, flags | flag) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn no_flow_control(slave: &OwnedFd) -> io::Result<()> {
let mut attributes: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(slave.as_raw_fd(), &raw mut attributes) } != 0 {
return Err(io::Error::last_os_error());
}
attributes.c_iflag &= !(libc::IXON | libc::IXOFF);
attributes.c_iflag |= libc::IUTF8;
if unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &raw const attributes) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn winsize(cols: u16, rows: u16) -> libc::winsize {
libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
}
}
struct Closing<'a>(&'a Pty);
impl Drop for Closing<'_> {
fn drop(&mut self) {
let mut io = lock(&self.0.io);
io.closed = true;
io.queue.clear();
io.offset = 0;
drop(io);
self.0.progressed.notify_waiters();
}
}
struct Ready {
readable: bool,
writable: bool,
woken: bool,
}
fn read_now(fd: RawFd, buffer: &mut [u8]) -> io::Result<Option<usize>> {
let read = unsafe { libc::read(fd, buffer.as_mut_ptr().cast(), buffer.len()) };
if read >= 0 {
return Ok(Some(read.cast_unsigned()));
}
let error = io::Error::last_os_error();
match error.raw_os_error() {
Some(libc::EIO) => Ok(Some(0)),
Some(code) if code == libc::EAGAIN || code == libc::EINTR => Ok(None),
_ => Err(error),
}
}
fn write_now(fd: RawFd, bytes: &[u8]) -> io::Result<Option<usize>> {
let wrote = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) };
if wrote >= 0 {
return Ok(Some(wrote.cast_unsigned()));
}
let error = io::Error::last_os_error();
match error.raw_os_error() {
Some(code) if code == libc::EAGAIN || code == libc::EINTR => Ok(None),
_ => Err(error),
}
}
impl Pty {
pub(crate) fn run_io(&self, mut output: impl FnMut(&[u8]) -> Vec<u8>) -> io::Result<()> {
let _closing = Closing(self);
let mut buffer = vec![0u8; READ_CHUNK];
loop {
let pending = {
let io = lock(&self.io);
if io.stop {
return Ok(());
}
!io.queue.is_empty()
};
let ready = self.wait(pending)?;
if ready.woken {
let mut drained = [0u8; 64];
while matches!(
read_now(self.wake_read.as_raw_fd(), &mut drained),
Ok(Some(1..))
) {}
}
if ready.readable {
match read_now(self.master.as_raw_fd(), &mut buffer)? {
Some(0) => return Ok(()),
Some(read) => {
let reply = output(&buffer[..read]);
if !reply.is_empty() {
let mut io = lock(&self.io);
let at = usize::from(io.offset > 0).min(io.queue.len());
io.queue.insert(
at,
Chunk {
input: None,
bytes: reply,
},
);
}
}
None => {}
}
}
if ready.writable {
self.write_some()?;
}
}
}
fn wait(&self, pending: bool) -> io::Result<Ready> {
let master = self.master.as_raw_fd();
let wake = self.wake_read.as_raw_fd();
unsafe {
let mut read: libc::fd_set = std::mem::zeroed();
let mut write: libc::fd_set = std::mem::zeroed();
libc::FD_ZERO(&raw mut read);
libc::FD_ZERO(&raw mut write);
libc::FD_SET(master, &raw mut read);
libc::FD_SET(wake, &raw mut read);
if pending {
libc::FD_SET(master, &raw mut write);
}
let mut timeout = libc::timeval {
tv_sec: 0,
tv_usec: libc::suseconds_t::try_from(IO_WAIT.as_micros()).unwrap_or(500_000),
};
let ready = libc::select(
master.max(wake) + 1,
&raw mut read,
&raw mut write,
std::ptr::null_mut(),
&raw mut timeout,
);
if ready < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
return Ok(Ready {
readable: false,
writable: false,
woken: false,
});
}
return Err(error);
}
Ok(Ready {
readable: libc::FD_ISSET(master, &raw const read),
writable: pending && libc::FD_ISSET(master, &raw const write),
woken: libc::FD_ISSET(wake, &raw const read),
})
}
}
fn write_some(&self) -> io::Result<()> {
let mut io = lock(&self.io);
let offset = io.offset;
let Some(chunk) = io.queue.front() else {
return Ok(());
};
let Some(wrote) = write_now(self.master.as_raw_fd(), &chunk.bytes[offset..])? else {
return Ok(());
};
let complete = offset + wrote >= chunk.bytes.len();
if !complete {
io.offset = offset + wrote;
return Ok(());
}
io.offset = 0;
if let Some(Chunk {
input: Some(number),
..
}) = io.queue.pop_front()
{
io.done = number;
drop(io);
self.progressed.notify_waiters();
}
Ok(())
}
fn wake(&self) {
let _ = write_now(self.wake_write.as_raw_fd(), b"!");
}
pub(crate) fn stop(&self) {
lock(&self.io).stop = true;
self.wake();
}
pub(crate) async fn type_input(&self, bytes: &[u8], deadline: Instant) -> usize {
if bytes.is_empty() {
return 0;
}
let number = {
let mut io = lock(&self.io);
if io.closed || io.stop {
return 0;
}
io.next += 1;
let number = io.next;
io.queue.push_back(Chunk {
input: Some(number),
bytes: bytes.to_vec(),
});
number
};
self.wake();
loop {
let progressed = self.progressed.notified();
{
let io = lock(&self.io);
if io.done >= number {
return bytes.len();
}
if io.closed {
break;
}
}
if tokio::time::timeout_at(deadline, progressed).await.is_err() {
break;
}
}
let mut io = lock(&self.io);
if io.done >= number {
return bytes.len();
}
let Some(index) = io
.queue
.iter()
.position(|chunk| chunk.input == Some(number))
else {
return 0;
};
let taken = if index == 0 {
std::mem::take(&mut io.offset)
} else {
0
};
io.queue.remove(index);
taken
}
pub(crate) fn resize(&self, cols: u16, rows: u16) -> io::Result<()> {
let size = winsize(cols, rows);
if unsafe { libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &raw const size) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
#[cfg(test)]
mod tests;