pub mod cwd;
pub mod ring;
pub mod scrollback;
pub mod session;
pub mod terminal_ws;
pub mod vt;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use portable_pty::{CommandBuilder, PtySize};
use sqlx::SqlitePool;
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
use crate::agent::state::AgentSnapshot;
use crate::engine::pty::ring::{ByteRing, DEFAULT_REPLAY_BYTES};
use crate::engine::pty::session::PtySession;
use crate::engine::pty::vt::VtState;
use crate::engine::pty_io;
use crate::engine::{EngineSessionInfo, SessionEngine, WatchTarget};
use crate::models::session::RuntimeKind;
const OUTPUT_CHANNEL_FRAMES: usize = 256;
const ACTIVE_WINDOW: Duration = Duration::from_secs(2);
const DEFAULT_SIZE: PtySize = PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 };
const FLUSH_INTERVAL: Duration = Duration::from_secs(5);
const CWD_SAMPLE_INTERVAL: Duration = Duration::from_secs(30);
struct Output {
ring: ByteRing,
tx: broadcast::Sender<Vec<u8>>,
}
struct SessionState {
session: Arc<PtySession>,
out: Mutex<Output>,
vt: Mutex<VtState>,
last_activity: Mutex<Instant>,
output_seq: AtomicU64,
flushed_seq: AtomicU64,
last_written_cwd: Mutex<Option<String>>,
size: Mutex<PtySize>,
created: String,
spawn_cwd: String,
}
struct Inner {
sessions: Mutex<HashMap<String, Arc<SessionState>>>,
}
#[derive(Clone)]
pub struct PtyEngine {
inner: Arc<Inner>,
}
impl Default for PtyEngine {
fn default() -> Self {
Self::new()
}
}
impl PtyEngine {
pub fn new() -> Self {
Self { inner: Arc::new(Inner { sessions: Mutex::new(HashMap::new()) }) }
}
pub fn with_db(db: SqlitePool) -> Self {
let engine = Self { inner: Arc::new(Inner { sessions: Mutex::new(HashMap::new()) }) };
engine.spawn_flush_task();
engine.spawn_cwd_sampler(db);
engine
}
fn spawn_flush_task(&self) {
let inner = Arc::clone(&self.inner);
tokio::spawn(async move {
loop {
tokio::time::sleep(FLUSH_INTERVAL).await;
let states: Vec<(String, Arc<SessionState>)> = inner
.sessions
.lock()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), Arc::clone(v)))
.collect();
for (key, state) in states {
flush_if_dirty(&key, &state);
}
}
});
}
fn spawn_cwd_sampler(&self, db: SqlitePool) {
let inner = Arc::clone(&self.inner);
tokio::spawn(async move {
loop {
tokio::time::sleep(CWD_SAMPLE_INTERVAL).await;
let states: Vec<(String, Arc<SessionState>)> = inner
.sessions
.lock()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), Arc::clone(v)))
.collect();
for (key, state) in states {
let Some(cwd) = cwd::session_cwd(state.session.child_pid()) else {
continue;
};
let cwd_str = cwd.to_string_lossy().into_owned();
if *state.last_written_cwd.lock().unwrap() == Some(cwd_str.clone()) {
continue;
}
match sqlx::query(
"UPDATE sessions SET last_cwd = ? WHERE tmux_session_name = ?",
)
.bind(&cwd_str)
.bind(&key)
.execute(&db)
.await
{
Ok(_) => {
*state.last_written_cwd.lock().unwrap() = Some(cwd_str);
}
Err(e) => warn!("failed to write back last_cwd for {key}: {e}"),
}
}
}
});
}
}
pub struct PtyAttach {
pub replay: Vec<u8>,
pub rx: broadcast::Receiver<Vec<u8>>,
pub reconnected: bool,
state: Arc<SessionState>,
}
impl Clone for PtyAttach {
fn clone(&self) -> Self {
let rx = self.state.out.lock().unwrap().tx.subscribe();
Self { replay: Vec::new(), rx, reconnected: false, state: Arc::clone(&self.state) }
}
}
impl PtyAttach {
pub fn write(&self, data: &[u8]) -> Result<()> {
let mut written = 0;
while written < data.len() {
match self.state.session.write(&data[written..]) {
Ok(0) => return Err(anyhow!("pty write returned 0")),
Ok(n) => written += n,
Err(e) => return Err(anyhow!("pty write failed: {e}")),
}
}
Ok(())
}
pub fn resize(&self, size: PtySize) -> Result<()> {
self.state.session.resize(size).map_err(|e| anyhow!("pty resize failed: {e}"))?;
self.state.vt.lock().unwrap().resize(size.rows, size.cols);
*self.state.size.lock().unwrap() = size;
Ok(())
}
}
impl PtyEngine {
pub fn attach(&self, key: &str, cwd: &str, size: PtySize) -> Result<PtyAttach> {
let (state, reconnected) = self.resolve_or_create(key, cwd, size)?;
let (replay, rx) = {
let g = state.out.lock().unwrap();
(g.ring.snapshot(), g.tx.subscribe())
};
Ok(PtyAttach { replay, rx, reconnected, state })
}
fn get(&self, key: &str) -> Option<Arc<SessionState>> {
self.inner.sessions.lock().unwrap().get(key).cloned()
}
fn resolve_or_create(
&self,
key: &str,
cwd: &str,
size: PtySize,
) -> Result<(Arc<SessionState>, bool)> {
let mut sessions = self.inner.sessions.lock().unwrap();
if let Some(existing) = sessions.get(key) {
return Ok((Arc::clone(existing), true));
}
let state = self.spawn_session(key, cwd, size)?;
sessions.insert(key.to_string(), Arc::clone(&state));
info!("pty session created: {} (cwd: {}, {}x{})", key, cwd, size.cols, size.rows);
Ok((state, false))
}
fn spawn_session(&self, key: &str, cwd: &str, size: PtySize) -> Result<Arc<SessionState>> {
let mut cmd = CommandBuilder::new(if cfg!(windows) { "cmd.exe" } else { "bash" });
cmd.cwd(cwd);
cmd.env("TERM", "xterm-256color");
let session = Arc::new(PtySession::spawn(cmd, size).map_err(|e| anyhow!("{e}"))?);
let mut reader = session.try_clone_reader().map_err(|e| anyhow!("{e}"))?;
let vt = VtState::new(size.rows, size.cols);
let (tx, _rx) = broadcast::channel::<Vec<u8>>(OUTPUT_CHANNEL_FRAMES);
let state = Arc::new(SessionState {
session,
out: Mutex::new(Output { ring: ByteRing::new(DEFAULT_REPLAY_BYTES), tx }),
vt: Mutex::new(vt),
last_activity: Mutex::new(Instant::now()),
output_seq: AtomicU64::new(0),
flushed_seq: AtomicU64::new(0),
last_written_cwd: Mutex::new(None),
size: Mutex::new(size),
created: chrono::Utc::now().to_rfc3339(),
spawn_cwd: cwd.to_string(),
});
if let Some(seed) = scrollback::load(key)
&& !seed.is_empty()
{
state.out.lock().unwrap().ring.push(&seed);
state.vt.lock().unwrap().feed(&seed);
state.vt.lock().unwrap().take_responses();
debug!("pty session rebuilt with {} bytes of history: {key}", seed.len());
}
let reader_state = Arc::clone(&state);
let reader_inner = Arc::clone(&self.inner);
let reader_key = key.to_string();
tokio::task::spawn_blocking(move || {
use std::io::Read;
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let chunk = buf[..n].to_vec();
{
let mut g = reader_state.out.lock().unwrap();
g.ring.push(&chunk);
let _ = g.tx.send(chunk.clone()); }
let responses = {
let mut vt = reader_state.vt.lock().unwrap();
vt.feed(&chunk);
vt.take_responses()
};
if !responses.is_empty()
&& should_server_respond(&reader_state)
&& let Err(e) = reader_state.session.write(&responses)
{
warn!("failed to write VT response to pty ({reader_key}): {e}");
}
*reader_state.last_activity.lock().unwrap() = Instant::now();
reader_state.output_seq.fetch_add(1, Ordering::Relaxed);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
unregister_if_same(&reader_inner, &reader_key, &reader_state);
debug!("pty reader exited: session={reader_key}");
});
if let Some(mut child) = state.session.take_child() {
let reaper_inner = Arc::clone(&self.inner);
let reaper_state = Arc::clone(&state);
let reaper_key = key.to_string();
tokio::task::spawn_blocking(move || {
let code = child.wait().ok().map(|s| s.exit_code());
info!("pty session exited: {reaper_key} code={code:?}");
unregister_if_same(&reaper_inner, &reaper_key, &reaper_state);
});
}
Ok(state)
}
}
fn should_server_respond(state: &SessionState) -> bool {
state.out.lock().unwrap().tx.receiver_count() == 0
}
fn unregister_if_same(inner: &Inner, key: &str, state: &Arc<SessionState>) {
let mut sessions = inner.sessions.lock().unwrap();
if sessions.get(key).is_some_and(|s| Arc::ptr_eq(s, state)) {
sessions.remove(key);
flush_if_dirty(key, state);
}
}
fn flush_if_dirty(key: &str, state: &SessionState) {
let seq = state.output_seq.load(Ordering::Relaxed);
if seq == state.flushed_seq.load(Ordering::Relaxed) {
return;
}
let snapshot = state.out.lock().unwrap().ring.snapshot();
match scrollback::save(key, &snapshot) {
Ok(()) => {
state.flushed_seq.store(seq, Ordering::Relaxed);
}
Err(e) => warn!("failed to persist pty history for {key}: {e}"),
}
}
impl SessionEngine for PtyEngine {
async fn create_session(&self, name: &str, cwd: &str, command: Option<&str>) -> Result<bool> {
let (state, _) = self.resolve_or_create(name, cwd, DEFAULT_SIZE)?;
if let Some(cmd) = command {
let mut bytes = cmd.as_bytes().to_vec();
bytes.push(b'\r');
let rx = state.out.lock().unwrap().tx.subscribe();
let attach = PtyAttach { replay: Vec::new(), rx, reconnected: false, state };
attach.write(&bytes)?;
}
Ok(false) }
async fn kill_session(&self, name: &str) -> Result<()> {
let state = self.inner.sessions.lock().unwrap().remove(name);
let Some(state) = state else {
return Ok(());
};
let pid = state.session.child_pid();
tokio::task::spawn_blocking(move || {
if let Some(pid) = pid {
pty_io::kill_process_escalating(pid);
}
})
.await
.map_err(|e| anyhow!("kill task join failed: {e}"))?;
state.session.close_master();
scrollback::remove(name);
info!("pty session killed: {name}");
Ok(())
}
async fn session_exists(&self, name: &str) -> bool {
self.inner.sessions.lock().unwrap().contains_key(name)
}
async fn list_sessions(&self) -> Result<Vec<EngineSessionInfo>> {
let sessions = self.inner.sessions.lock().unwrap();
Ok(sessions
.iter()
.map(|(name, st)| {
let attached = st.out.lock().unwrap().tx.receiver_count() > 0;
let cwd = cwd::session_cwd(st.session.child_pid())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| st.spawn_cwd.clone());
EngineSessionInfo {
name: name.clone(),
attached,
windows: 1,
created: st.created.clone(),
cwd: Some(cwd),
agent_kind: None,
agent_state: None,
attention_reason: None,
agent_event: None,
agent_nonce: None,
}
})
.collect())
}
async fn current_cwd(&self, name: &str) -> Result<String> {
let state = self.get(name).ok_or_else(|| anyhow!("pty session '{name}' not found"))?;
Ok(cwd::session_cwd(state.session.child_pid())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| state.spawn_cwd.clone()))
}
async fn capture_screen(&self, name: &str) -> Result<String> {
let state = self.get(name).ok_or_else(|| anyhow!("pty session '{name}' not found"))?;
Ok(state.vt.lock().unwrap().capture_visible())
}
async fn agent_snapshot(&self, _name: &str) -> Result<Option<AgentSnapshot>> {
Ok(None)
}
async fn is_active(&self, name: &str) -> bool {
self.get(name)
.is_some_and(|st| *st.last_activity.lock().unwrap() + ACTIVE_WINDOW > Instant::now())
}
async fn track_session(&self, _name: &str) -> Result<()> {
Ok(()) }
async fn untrack_session(&self, _name: &str) {}
async fn watch_targets(&self) -> Vec<WatchTarget> {
let sessions = self.inner.sessions.lock().unwrap();
sessions
.iter()
.map(|(name, st)| WatchTarget {
kind: RuntimeKind::Pty,
session: name.clone(),
pane_pid: st.session.child_pid().unwrap_or(0),
activity: st.output_seq.load(Ordering::Relaxed).to_string(),
title: st.vt.lock().unwrap().title(), })
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn engine() -> PtyEngine {
PtyEngine::new()
}
async fn wait_for_output(rx: &mut broadcast::Receiver<Vec<u8>>, needle: &[u8]) -> Vec<u8> {
let mut acc = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Ok(chunk)) => {
acc.extend_from_slice(&chunk);
if acc.windows(needle.len()).any(|w| w == needle) {
return acc;
}
}
Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue,
Ok(Err(broadcast::error::RecvError::Closed)) => break,
Err(_) => continue,
}
}
acc
}
#[tokio::test]
async fn dsr_response_gated_by_attach_state() {
let engine = engine();
let tmp = std::env::temp_dir();
let attach = engine.attach("dsr-gate", tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
let state = engine.get("dsr-gate").unwrap();
assert!(!should_server_respond(&state), "attached: server must stay silent");
drop(attach);
assert!(should_server_respond(&state), "detached: server must respond");
engine.kill_session("dsr-gate").await.unwrap();
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn spawn_cwd_matches_requested() {
let engine = engine();
let cwd = std::env::temp_dir();
engine.attach("cwd-check", cwd.to_str().unwrap(), DEFAULT_SIZE).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
let state = engine.get("cwd-check").unwrap();
let pid = state.session.child_pid().unwrap();
let actual = std::fs::read_link(format!("/proc/{pid}/cwd")).unwrap();
assert_eq!(std::fs::canonicalize(actual).unwrap(), std::fs::canonicalize(&cwd).unwrap());
engine.kill_session("cwd-check").await.unwrap();
}
#[tokio::test]
async fn resident_session_survives_detach_and_replays_output() {
let engine = engine();
let tmp = std::env::temp_dir();
let attach = engine.attach("reconnect", tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
let writer = attach.clone();
let mut rx = attach.rx;
writer.write(b"echo MARKER_FIRST\n").unwrap();
let out = wait_for_output(&mut rx, b"MARKER_FIRST").await;
assert!(out.windows(12).any(|w| w == b"MARKER_FIRST"), "first attach saw no marker");
drop(rx);
drop(writer);
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(engine.session_exists("reconnect").await, "detach must not kill the session");
let attach2 = engine.attach("reconnect", tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
assert!(
attach2.replay.windows(12).any(|w| w == b"MARKER_FIRST"),
"replay after reconnect must contain pre-detach output"
);
let writer2 = attach2.clone();
let mut rx2 = attach2.rx;
writer2.write(b"echo MARKER_SECOND\n").unwrap();
let out2 = wait_for_output(&mut rx2, b"MARKER_SECOND").await;
assert!(
out2.windows(13).any(|w| w == b"MARKER_SECOND"),
"shell must stay interactive after reconnect"
);
engine.kill_session("reconnect").await.unwrap();
assert!(!engine.session_exists("reconnect").await);
}
#[tokio::test]
async fn kill_removes_session_and_terminates_process() {
let engine = engine();
let tmp = std::env::temp_dir();
let attach = engine.attach("killme", tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
let state = engine.get("killme").unwrap();
let pid = state.session.child_pid().unwrap();
drop(attach);
engine.kill_session("killme").await.unwrap();
assert!(!engine.session_exists("killme").await);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let alive = std::path::Path::new(&format!("/proc/{pid}")).exists();
if !alive {
break;
}
assert!(std::time::Instant::now() < deadline, "pid {pid} survived kill");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn capture_screen_returns_clean_text_via_vt() {
let engine = engine();
let tmp = std::env::temp_dir();
let attach = engine.attach("vt-capture", tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
let writer = attach.clone();
let mut rx = attach.rx;
writer.write(b"printf '\\033[1;32mVT_MARKER\\033[0m\\n'\n").unwrap();
let out = wait_for_output(&mut rx, b"VT_MARKER").await;
assert!(out.windows(9).any(|w| w == b"VT_MARKER"), "echo output missing");
let cap = engine.capture_screen("vt-capture").await.unwrap();
assert!(cap.contains("VT_MARKER"), "capture must contain the marker, got: {cap:?}");
assert!(!cap.contains('\x1b'), "capture must not contain escape bytes");
engine.kill_session("vt-capture").await.unwrap();
}
#[tokio::test]
async fn exit_persists_history_and_rebuild_seeds_it() {
let engine = engine();
let tmp = std::env::temp_dir();
let key = "rebuild-seed-test";
let attach = engine.attach(key, tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
let writer = attach.clone();
let mut rx = attach.rx;
writer.write(b"echo REBUILD_MARK\n").unwrap();
let out = wait_for_output(&mut rx, b"REBUILD_MARK").await;
assert!(out.windows(12).any(|w| w == b"REBUILD_MARK"));
writer.write(b"exit\n").unwrap();
drop(rx);
drop(writer);
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while engine.session_exists(key).await {
assert!(tokio::time::Instant::now() < deadline, "session not unregistered");
tokio::time::sleep(Duration::from_millis(50)).await;
}
let persisted = scrollback::load(key).expect("history must be persisted on exit");
assert!(
persisted.windows(12).any(|w| w == b"REBUILD_MARK"),
"persisted history must contain the output"
);
let attach2 = engine.attach(key, tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
assert!(
attach2.replay.windows(12).any(|w| w == b"REBUILD_MARK"),
"rebuild replay must be seeded from persisted history"
);
let cap = engine.capture_screen(key).await.unwrap();
assert!(cap.contains("REBUILD_MARK"), "rebuild capture must see seeded history");
engine.kill_session(key).await.unwrap();
assert!(scrollback::load(key).is_none(), "kill must remove persisted history");
}
#[tokio::test]
async fn child_exit_unregisters_session() {
let engine = engine();
let tmp = std::env::temp_dir();
let attach = engine.attach("exit-self", tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
attach.write(b"exit\n").unwrap();
drop(attach);
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while engine.session_exists("exit-self").await {
assert!(tokio::time::Instant::now() < deadline, "session not unregistered after exit");
tokio::time::sleep(Duration::from_millis(50)).await;
}
scrollback::remove("exit-self");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn no_fd_leak_across_create_kill_cycle() {
let count_fds = || std::fs::read_dir("/proc/self/fd").unwrap().count();
let engine = engine();
let tmp = std::env::temp_dir();
let baseline = count_fds();
for i in 0..3 {
let key = format!("fd-leak-{i}");
let attach = engine.attach(&key, tmp.to_str().unwrap(), DEFAULT_SIZE).unwrap();
drop(attach);
engine.kill_session(&key).await.unwrap();
}
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let now = count_fds();
if now <= baseline + 2 {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"fd leak: baseline={baseline}, now={now}"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}