panasyn 0.1.0

A lightweight GPU-accelerated terminal emulator for macOS and Linux.
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);

        // SAFETY: forkpty is called with a valid Winsize pointer. The child branch
        // immediately uses async-signal-safe libc calls and exits if execve fails.
        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 })
            }
            // SAFETY: In the post-fork child, only raw libc calls are made before
            // replacing the process image or exiting. argv/env pointers reference
            // CStrings that remain alive for this branch.
            ForkptyResult::Child => unsafe {
                // Restore the child's signal mask so app-level signal watchers do
                // not leak blocked signals into the interactive shell.
                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,
        };
        // SAFETY: self.master is a live PTY master fd and ws points to a valid
        // Winsize value for the duration of the ioctl call.
        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
    );
}