use std::ffi::CString;
use std::io;
use std::os::fd::OwnedFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::io::{AsFd, AsRawFd};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use log::*;
use nix::pty::{ForkptyResult, Winsize, forkpty};
use nix::sys::signal::Signal;
use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
use crate::UserEvent;
pub struct Session {
child: nix::unistd::Pid,
master: OwnedFd,
}
impl Session {
pub fn spawn(shell: &str, term: &str, cols: u16, rows: u16) -> io::Result<Self> {
let shell_path = resolve_shell_path(shell)?;
let shell_cstr = cstring_from_bytes(shell_path.as_os_str().as_bytes(), "shell path")?;
let argv = vec![shell_cstr.clone()];
let env = child_env(term)?;
let argv_ptrs = cstring_ptrs(&argv);
let env_ptrs = cstring_ptrs(&env);
let winsize = Winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
info!("Spawning PTY: shell={} cols={} rows={}", shell, cols, rows);
match unsafe { forkpty(Some(&winsize), None) }.map_err(io::Error::other)? {
ForkptyResult::Parent { child, master } => {
info!("PTY spawned, child PID={}", child);
Ok(Self { child, master })
}
ForkptyResult::Child => unsafe {
let mut empty: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut empty);
libc::sigprocmask(libc::SIG_SETMASK, &empty, std::ptr::null_mut());
libc::execve(shell_cstr.as_ptr(), argv_ptrs.as_ptr(), env_ptrs.as_ptr());
let msg = b"panasyn: execve failed\n";
libc::write(libc::STDERR_FILENO, msg.as_ptr() as *const _, msg.len());
libc::_exit(127);
},
}
}
pub fn write(&self, data: &[u8]) -> io::Result<usize> {
let mut written = 0;
while written < data.len() {
match nix::unistd::write(&self.master, &data[written..]) {
Ok(0) => return Err(io::Error::other("PTY write returned 0")),
Ok(n) => written += n,
Err(e) => return Err(io::Error::other(e)),
}
}
Ok(written)
}
pub fn resize(&self, cols: u16, rows: u16) -> io::Result<()> {
let ws = Winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
if libc::ioctl(
self.master.as_raw_fd(),
libc::TIOCSWINSZ,
&ws as *const Winsize as *const libc::c_void,
) != 0
{
return Err(io::Error::last_os_error());
}
}
let _ = nix::sys::signal::kill(self.child, Signal::SIGWINCH);
Ok(())
}
pub fn master(&self) -> &OwnedFd {
&self.master
}
}
fn resolve_shell_path(shell: &str) -> io::Result<PathBuf> {
if shell.contains('/') {
let path = PathBuf::from(shell);
return validate_executable_shell(path);
}
let path = std::env::var_os("PATH").unwrap_or_else(|| "/bin:/usr/bin".into());
for dir in std::env::split_paths(&path) {
let candidate = dir.join(shell);
if is_executable_file(&candidate) {
return Ok(candidate);
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("shell not found in PATH: {shell}"),
))
}
fn validate_executable_shell(path: PathBuf) -> io::Result<PathBuf> {
if is_executable_file(&path) {
return Ok(path);
}
let kind = if path.exists() {
io::ErrorKind::PermissionDenied
} else {
io::ErrorKind::NotFound
};
Err(io::Error::new(
kind,
format!("shell is not executable: {}", path.display()),
))
}
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
fn child_env(term: &str) -> io::Result<Vec<CString>> {
let mut env = Vec::new();
let mut has_lang = false;
for (key, value) in std::env::vars_os() {
let key_bytes = key.as_os_str().as_bytes();
if matches!(key_bytes, b"TERM" | b"COLORTERM") {
continue;
}
if key_bytes == b"LANG" {
has_lang = true;
}
let value_bytes = value.as_os_str().as_bytes();
let mut entry = Vec::with_capacity(key_bytes.len() + 1 + value_bytes.len());
entry.extend_from_slice(key_bytes);
entry.push(b'=');
entry.extend_from_slice(value_bytes);
env.push(cstring_from_bytes(&entry, "environment entry")?);
}
let term = if term.trim().is_empty() {
"xterm-256color"
} else {
term.trim()
};
env.push(cstring_from_bytes(
format!("TERM={term}").as_bytes(),
"TERM environment entry",
)?);
env.push(CString::new("COLORTERM=truecolor").expect("static env has no nul"));
if !has_lang {
env.push(CString::new("LANG=C.UTF-8").expect("static env has no nul"));
}
#[cfg(feature = "agent-harness")]
if agent_auto_flow_enabled() {
env.push(CString::new("PANASYN_AGENT_AUTO_FLOW=1").expect("static env has no nul"));
}
Ok(env)
}
#[cfg(feature = "agent-harness")]
fn agent_auto_flow_enabled() -> bool {
std::env::var("PANASYN_AGENT_AUTO_FLOW").as_deref() == Ok("1")
|| agent_bundle_resource("agent-auto-flow").is_some_and(|path| path.is_file())
}
#[cfg(feature = "agent-harness")]
fn agent_bundle_resource(name: &str) -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos = exe.parent()?;
if macos.file_name()? != "MacOS" {
return None;
}
let contents = macos.parent()?;
if contents.file_name()? != "Contents" {
return None;
}
Some(contents.join("Resources").join(name))
}
fn cstring_from_bytes(bytes: &[u8], label: &str) -> io::Result<CString> {
CString::new(bytes).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{label} contains a nul byte"),
)
})
}
fn cstring_ptrs(values: &[CString]) -> Vec<*const libc::c_char> {
let mut ptrs: Vec<*const libc::c_char> = values.iter().map(|value| value.as_ptr()).collect();
ptrs.push(std::ptr::null());
ptrs
}
impl Drop for Session {
fn drop(&mut self) {
info!("Shutting down PTY session (PID={})", self.child);
let _ = nix::sys::signal::kill(self.child, Signal::SIGHUP);
let _ = nix::sys::signal::kill(self.child, Signal::SIGTERM);
for _ in 0..10 {
match waitpid(self.child, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::StillAlive) => {
std::thread::sleep(std::time::Duration::from_millis(10))
}
Ok(_) | Err(nix::errno::Errno::ECHILD) => return,
Err(e) => {
debug!("PTY waitpid error during shutdown: {}", e);
return;
}
}
}
let _ = nix::sys::signal::kill(self.child, Signal::SIGKILL);
let _ = waitpid(self.child, Some(WaitPidFlag::WNOHANG));
}
}
pub fn reader_thread(
session_id: u64,
master: OwnedFd,
proxy: winit::event_loop::EventLoopProxy<UserEvent>,
stop: Arc<AtomicBool>,
) {
let mut buf = vec![0u8; 128 * 1024];
let mut total_read: u64 = 0;
const MAX_BATCH_BYTES: usize = 1024 * 1024;
while !stop.load(Ordering::Relaxed) {
let poll_fd = nix::poll::PollFd::new(
master.as_fd(),
nix::poll::PollFlags::POLLIN | nix::poll::PollFlags::POLLHUP,
);
let mut poll_fds = [poll_fd];
match nix::poll::poll(&mut poll_fds, nix::poll::PollTimeout::from(100u16)) {
Ok(0) => continue,
Ok(_) => {}
Err(nix::errno::Errno::EINTR) => continue,
Err(e) => {
error!("PTY poll error: {}", e);
let _ = proxy.send_event(UserEvent::PtyClosed { session_id });
break;
}
}
let revents = poll_fds[0]
.revents()
.unwrap_or(nix::poll::PollFlags::empty());
let mut saw_hup = revents.contains(nix::poll::PollFlags::POLLHUP);
if revents.contains(nix::poll::PollFlags::POLLIN) {
let mut batch = Vec::with_capacity(buf.len().min(MAX_BATCH_BYTES));
loop {
match nix::unistd::read(&master, &mut buf) {
Ok(0) => {
debug!("PTY EOF (total read={} bytes)", total_read);
saw_hup = true;
break;
}
Ok(n) => {
total_read += n as u64;
batch.extend_from_slice(&buf[..n]);
trace!("PTY read {} bytes (total={})", n, total_read);
}
Err(nix::errno::Errno::EINTR) => continue,
Err(e) => {
error!("PTY read error: {} (total read={} bytes)", e, total_read);
saw_hup = true;
break;
}
}
if batch.len() >= MAX_BATCH_BYTES {
break;
}
let poll_fd = nix::poll::PollFd::new(
master.as_fd(),
nix::poll::PollFlags::POLLIN | nix::poll::PollFlags::POLLHUP,
);
let mut poll_fds = [poll_fd];
match nix::poll::poll(&mut poll_fds, nix::poll::PollTimeout::ZERO) {
Ok(0) => break,
Ok(_) => {
let next = poll_fds[0]
.revents()
.unwrap_or(nix::poll::PollFlags::empty());
saw_hup |= next.contains(nix::poll::PollFlags::POLLHUP);
if !next.contains(nix::poll::PollFlags::POLLIN) {
break;
}
}
Err(nix::errno::Errno::EINTR) => continue,
Err(e) => {
error!("PTY poll error while draining: {}", e);
saw_hup = true;
break;
}
}
}
if !batch.is_empty()
&& proxy
.send_event(UserEvent::PtyOutput {
session_id,
data: batch,
})
.is_err()
{
break;
}
}
if saw_hup {
trace!("PTY POLLHUP");
let _ = proxy.send_event(UserEvent::PtyClosed { session_id });
break;
}
}
debug!(
"PTY reader thread exiting (total read={} bytes)",
total_read
);
}