Files
dev/third_party/pi/packages/env/daemon/src/pty.rs
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

557 lines
19 KiB
Rust

//! `pty`: a program in a pseudo-terminal, for interactive terminals. Unlike `exec`, output is the terminal's raw
//! bytes, and the program reads input: `ptyWrite`, `ptyResize` and `ptyForeground` address the running `pty` request by
//! its id.
use crate::errors::Failure;
use crate::exec::{Control, Groups};
#[cfg(unix)]
use crate::exec::{Message, kill_group};
#[cfg(unix)]
use crate::frame::{EVENT, Frame};
use crate::output::Output;
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
/// After the program exits, output still arriving is collected until this much silence.
#[cfg(unix)]
const EXIT_GRACE: std::time::Duration = std::time::Duration::from_millis(100);
/// Largest terminal size accepted, in cells.
const MAX_CELLS: u64 = 4096;
/// What `ptyWrite`, `ptyResize` and `ptyForeground` reach: the terminal of one running `pty` request.
pub struct Terminal {
input: Sender<Vec<u8>>,
#[cfg(unix)]
master: Arc<std::fs::File>,
/// The program's process id, which is also its process group's (it leads the terminal's session).
#[cfg(unix)]
pid: u32,
}
/// Running terminals by the id of their `pty` request.
pub type Terminals = Arc<Mutex<HashMap<u32, Arc<Terminal>>>>;
#[cfg_attr(not(unix), allow(dead_code))]
pub struct PtyRequest {
argv: Option<Vec<String>>,
cwd: String,
env: Map<String, Value>,
inherit_env: bool,
cols: u16,
rows: u16,
}
fn size(json: &Value, key: &str) -> Result<u16, Failure> {
match json[key].as_u64() {
Some(value) if (1..=MAX_CELLS).contains(&value) => Ok(value as u16),
_ => Err(Failure::new(
"EINVAL",
format!("{key} must be between 1 and {MAX_CELLS}"),
)),
}
}
impl PtyRequest {
pub fn from_json(json: &Value) -> Result<PtyRequest, Failure> {
let argv = match &json["argv"] {
Value::Null => None,
value => Some(
value
.as_array()
.and_then(|items| {
items
.iter()
.map(|item| item.as_str().map(str::to_string))
.collect()
})
.filter(|argv: &Vec<String>| !argv.is_empty())
.ok_or_else(|| {
Failure::new("EINVAL", "argv must be a non-empty array of strings")
})?,
),
};
Ok(PtyRequest {
argv,
cwd: json["cwd"]
.as_str()
.ok_or_else(|| Failure::new("EINVAL", "pty needs cwd"))?
.to_string(),
env: json["env"].as_object().cloned().unwrap_or_default(),
inherit_env: json["inheritEnv"].as_bool().unwrap_or(true),
cols: size(json, "cols")?,
rows: size(json, "rows")?,
})
}
}
fn find(terminals: &Terminals, json: &Value) -> Result<Arc<Terminal>, Failure> {
let target = json["target"]
.as_u64()
.ok_or_else(|| Failure::new("EINVAL", "missing field target"))?;
terminals
.lock()
.unwrap()
.get(&(target as u32))
.cloned()
.ok_or_else(|| Failure::new("EBADF", "no running terminal with this id"))
}
/// Queue input for the terminal; it is written in the order it arrives, without holding up other requests.
pub fn write(terminals: &Terminals, json: &Value, payload: Vec<u8>) -> Result<Value, Failure> {
let terminal = find(terminals, json)?;
terminal
.input
.send(payload)
.map_err(|_| Failure::new("EBADF", "the terminal has ended"))?;
Ok(json!({}))
}
/// Change the terminal's size; the program receives `SIGWINCH`.
pub fn resize(terminals: &Terminals, json: &Value) -> Result<Value, Failure> {
let terminal = find(terminals, json)?;
let (cols, rows) = (size(json, "cols")?, size(json, "rows")?);
#[cfg(unix)]
unix::set_size(&terminal.master, cols, rows)
.map_err(|error| Failure::io(&error, "ioctl", "pty"))?;
#[cfg(not(unix))]
let _ = (terminal, cols, rows);
Ok(json!({}))
}
/// The process group in the terminal's foreground when it is not the program's own: a job a shell started, such as an
/// editor. `{ foreground: null }` while the program itself is in front (a shell at its prompt).
pub fn foreground(terminals: &Terminals, json: &Value) -> Result<Value, Failure> {
let terminal = find(terminals, json)?;
#[cfg(unix)]
{
let group = unix::foreground(&terminal.master)
.map_err(|error| Failure::io(&error, "tcgetpgrp", "pty"))?;
if group <= 0 || group as u32 == terminal.pid {
return Ok(json!({ "foreground": null }));
}
let name = unix::process_name(group);
Ok(json!({ "foreground": { "pid": group, "name": name } }))
}
#[cfg(not(unix))]
{
let _ = terminal;
Ok(json!({ "foreground": null }))
}
}
#[cfg(not(unix))]
pub fn run(
_id: u32,
_request: PtyRequest,
_output: &Arc<Output>,
_control: &Control,
_groups: &Groups,
_terminals: &Terminals,
) -> Result<Value, Failure> {
Err(Failure::new(
"ENOTSUP",
"Terminals are not supported on this system yet",
))
}
/// Run the program in a new terminal, sending a `started` event and then its output, until it exits.
#[cfg(unix)]
pub fn run(
id: u32,
request: PtyRequest,
output: &Arc<Output>,
control: &Control,
groups: &Groups,
terminals: &Terminals,
) -> Result<Value, Failure> {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::{Duration, Instant};
let (sender, receiver) = mpsc::channel();
control.attach(sender.clone());
if control.aborted.load(Ordering::SeqCst) {
return Err(Failure::new("aborted", "aborted"));
}
if !std::path::Path::new(&request.cwd).exists() {
return Err(Failure::new(
"spawn_error",
format!("Working directory does not exist: {}", request.cwd),
));
}
let (master, mut child) =
unix::spawn(&request).map_err(|error| Failure::new("spawn_error", error.to_string()))?;
let master = Arc::new(master);
let pid = child.id();
groups.lock().unwrap().insert(pid);
let (input, inputs) = mpsc::channel::<Vec<u8>>();
terminals.lock().unwrap().insert(
id,
Arc::new(Terminal {
input,
master: master.clone(),
pid,
}),
);
output.control(Frame::new(
EVENT,
id,
json!({ "kind": "started", "pid": pid }),
));
let stop = Arc::new(AtomicBool::new(false));
let reader = {
let master = master.clone();
let sender = sender.clone();
let stop = stop.clone();
let output = output.clone();
thread::spawn(move || {
unix::read(&master, &stop, |bytes| {
let delivered = sender.send(Message::Data(0, bytes.to_vec())).is_ok();
// Like a full pipe: a program that writes faster than the link waits.
output.wait_for_room(&stop);
delivered
});
let _ = sender.send(Message::Eof(0));
})
};
{
let master = master.clone();
let stop = stop.clone();
thread::spawn(move || {
for bytes in inputs {
if !unix::write_all(&master, &bytes, &stop) {
return;
}
}
});
}
let waiter = sender.clone();
thread::spawn(move || {
let _ = waiter.send(match child.wait() {
Ok(status) => Message::Exit(status),
Err(error) => Message::WaitFailed(error.to_string()),
});
});
drop(sender);
let mut status = None;
let mut ended = false;
let mut idle_until: Option<Instant> = None;
let mut aborted = false;
let mut killed = false;
let kill = |killed: &mut bool| {
if !*killed {
*killed = true;
kill_group(pid);
}
};
if control.killed.load(Ordering::SeqCst) {
kill(&mut killed);
}
loop {
if status.is_some() && ended {
break;
}
let wait = match idle_until {
Some(idle) => {
let now = Instant::now();
if now >= idle {
// A background job may keep the terminal open after the program exited.
break;
}
idle - now
}
None => Duration::from_secs(3600),
};
match receiver.recv_timeout(wait) {
Ok(Message::Data(_, bytes)) => {
output.bulk(
Frame::with_payload(EVENT, id, json!({ "kind": "output" }), bytes),
None,
);
if status.is_some() {
idle_until = Some(Instant::now() + EXIT_GRACE);
}
}
Ok(Message::Eof(_)) => ended = true,
Ok(Message::Exit(exit)) => {
status = Some(Ok(exit));
idle_until = Some(Instant::now() + EXIT_GRACE);
}
Ok(Message::WaitFailed(error)) => {
status = Some(Err(error));
idle_until = Some(Instant::now());
}
Ok(Message::Cancel) => {
aborted = true;
kill(&mut killed);
}
Ok(Message::Kill) => kill(&mut killed),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
}
stop.store(true, Ordering::SeqCst);
// Ending the input channel ends the writer thread.
terminals.lock().unwrap().remove(&id);
groups.lock().unwrap().remove(&pid);
let _ = reader.join();
if aborted {
return Err(Failure::new("aborted", "aborted"));
}
match status {
Some(Ok(status)) => Ok(json!({ "exitCode": crate::sys::exit_code(status) })),
Some(Err(error)) => Err(Failure::new("spawn_error", error)),
None => Err(Failure::new(
"spawn_error",
"process did not report its exit",
)),
}
}
#[cfg(unix)]
mod unix {
use super::PtyRequest;
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::Value;
fn check(result: libc::c_int) -> io::Result<libc::c_int> {
if result < 0 {
Err(io::Error::last_os_error())
} else {
Ok(result)
}
}
fn winsize(cols: u16, rows: u16) -> libc::winsize {
libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
}
}
pub fn set_size(master: &File, cols: u16, rows: u16) -> io::Result<()> {
let size = winsize(cols, rows);
// SAFETY: TIOCSWINSZ reads one winsize from a valid pointer.
check(unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ as _, &size) })?;
Ok(())
}
/// The terminal's foreground process group.
pub fn foreground(master: &File) -> io::Result<libc::pid_t> {
// SAFETY: tcgetpgrp on a descriptor the terminal owns.
let group = unsafe { libc::tcgetpgrp(master.as_raw_fd()) };
if group < 0 {
Err(io::Error::last_os_error())
} else {
Ok(group)
}
}
/// A process's name, if it still runs and the system tells.
#[cfg(target_vendor = "apple")]
pub fn process_name(pid: libc::pid_t) -> Option<String> {
let mut buffer = [0u8; 256];
// SAFETY: proc_name writes at most `buffer.len()` bytes and returns how many.
let length =
unsafe { libc::proc_name(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
(length > 0).then(|| String::from_utf8_lossy(&buffer[..length as usize]).into_owned())
}
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn process_name(pid: libc::pid_t) -> Option<String> {
let name = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
Some(name.trim_end().to_string()).filter(|name| !name.is_empty())
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
pub fn process_name(_pid: libc::pid_t) -> Option<String> {
None
}
fn cloexec(fd: libc::c_int) -> io::Result<()> {
// SAFETY: fcntl on a descriptor this function's caller owns.
unsafe {
let flags = check(libc::fcntl(fd, libc::F_GETFD))?;
check(libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC))?;
}
Ok(())
}
/// The program to run: `argv`, or the user's shell as a login shell (`-` before its name, as `login` does).
fn command(request: &PtyRequest) -> Command {
match &request.argv {
Some(argv) => {
let mut command = Command::new(&argv[0]);
command.args(&argv[1..]);
command
}
None => {
let shell = request
.env
.get("SHELL")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| std::env::var("SHELL").ok())
.filter(|shell| !shell.is_empty() && crate::sys::path_exists(shell))
.unwrap_or_else(|| "/bin/sh".into());
let name = shell.rsplit('/').next().unwrap_or("sh").to_string();
let mut command = Command::new(&shell);
command.arg0(format!("-{name}"));
command
}
}
}
pub fn spawn(request: &PtyRequest) -> io::Result<(File, Child)> {
let mut master: libc::c_int = -1;
let mut slave: libc::c_int = -1;
let mut size = winsize(request.cols, request.rows);
// SAFETY: openpty writes two descriptors; the name and termios are not requested.
check(unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
&raw mut size,
)
})?;
// SAFETY: openpty returned two new descriptors this function now owns.
let (master, slave) =
unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) };
cloexec(master.as_raw_fd())?;
cloexec(slave.as_raw_fd())?;
let slave = File::from(slave);
let mut command = command(request);
command
.current_dir(&request.cwd)
.stdin(Stdio::from(slave.try_clone()?))
.stdout(Stdio::from(slave.try_clone()?))
.stderr(Stdio::from(slave.try_clone()?));
if !request.inherit_env {
command.env_clear();
}
for (key, value) in &request.env {
if let Some(value) = value.as_str() {
command.env(key, value);
}
}
// SAFETY: only async-signal-safe calls between fork and exec.
unsafe {
command.pre_exec(|| {
// A new session whose controlling terminal is the pty (stdin), so job control and SIGHUP work; default
// signal handling and an empty mask, as for `exec`.
if libc::setsid() < 0 {
return Err(io::Error::last_os_error());
}
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 {
return Err(io::Error::last_os_error());
}
for signal in 1..32 {
if signal != libc::SIGKILL && signal != libc::SIGSTOP {
libc::signal(signal, libc::SIG_DFL);
}
}
let mut mask: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut mask);
libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
Ok(())
});
}
let child = command.spawn()?;
// Only the program holds the terminal now, so reads end once it and its descendants close it.
drop(slave);
let master = File::from(master);
// SAFETY: fcntl on the descriptor owned above; reads and writes poll instead of blocking.
unsafe {
let flags = check(libc::fcntl(master.as_raw_fd(), libc::F_GETFL))?;
check(libc::fcntl(
master.as_raw_fd(),
libc::F_SETFL,
flags | libc::O_NONBLOCK,
))?;
}
Ok((master, child))
}
fn poll(fd: libc::c_int, events: libc::c_short) -> io::Result<bool> {
let mut poll = libc::pollfd {
fd,
events,
revents: 0,
};
// SAFETY: one valid pollfd.
let ready = unsafe { libc::poll(&mut poll, 1, 100) };
if ready < 0 {
let error = io::Error::last_os_error();
return if error.kind() == io::ErrorKind::Interrupted {
Ok(false)
} else {
Err(error)
};
}
Ok(ready > 0)
}
/// Read until the terminal closes (Linux reports `EIO` once no program holds it), `on_data` returns false, or
/// `stop` is set.
pub fn read(master: &File, stop: &AtomicBool, mut on_data: impl FnMut(&[u8]) -> bool) {
let mut buffer = vec![0u8; 64 * 1024];
while !stop.load(Ordering::SeqCst) {
match poll(master.as_raw_fd(), libc::POLLIN) {
Ok(false) => continue,
Ok(true) => {}
Err(_) => return,
}
match (&*master).read(&mut buffer) {
Ok(0) => return,
Ok(read) => {
if !on_data(&buffer[..read]) {
return;
}
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) => {}
Err(_) => return,
}
}
}
/// Write all of `bytes`, waiting while the program does not read its input; false once the terminal is gone.
pub fn write_all(master: &File, mut bytes: &[u8], stop: &AtomicBool) -> bool {
while !bytes.is_empty() {
if stop.load(Ordering::SeqCst) {
return false;
}
match (&*master).write(bytes) {
Ok(0) => return false,
Ok(written) => bytes = &bytes[written..],
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if poll(master.as_raw_fd(), libc::POLLOUT).is_err() {
return false;
}
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(_) => return false,
}
}
true
}
}