use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use portable_pty::PtySize;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::broadcast::error::RecvError;
use tracing::{debug, error, info, trace, warn};
use crate::AppState;
use crate::engine::pty::events::SemanticEvent;
use crate::ws::terminal::{ClientControl, ServerControl, TerminalQuery};
use serde::Deserialize;
const MAX_PENDING_VIEWPORT_REQUESTS: usize = 4;
const SLOW_FRAME_US: u64 = 5_000;
const FULL_FRAME_INTERVAL_MS: u64 = 1000;
#[derive(Debug, Deserialize)]
struct ClientHello {
t: String,
supports_cell_frame: bool,
}
pub async fn handle_pty_terminal(
ws: WebSocket,
session_id: String,
query: TerminalQuery,
state: AppState,
) {
info!("terminal WS connected (pty): session={}", session_id);
let row: Option<(String, Option<String>, Option<String>, bool)> = sqlx::query_as(
"SELECT workspace_path, tmux_session_name, last_cwd, hook_enabled FROM sessions WHERE id = ?",
)
.bind(&session_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let Some((workspace, engine_key, last_cwd, hook_enabled)) = row else {
let (mut sender, _) = ws.split();
let msg =
serde_json::to_string(&ServerControl::Error { message: "session not found" }).unwrap();
let _ = sender.send(Message::Text(msg.into())).await;
return;
};
let key = engine_key.unwrap_or_else(|| session_id.clone());
let spawn_cwd = last_cwd.filter(|p| std::path::Path::new(p).is_dir()).unwrap_or(workspace);
let size = PtySize {
rows: query.rows.filter(|&r| r > 0 && r <= 1000).unwrap_or(24),
cols: query.cols.filter(|&c| c > 0 && c <= 1000).unwrap_or(80),
pixel_width: 0,
pixel_height: 0,
};
info!(
"terminal PTY initial size: {}x{} for session={} (pty key={})",
size.cols, size.rows, session_id, key
);
let attach = match state.engines.attach_pty(&key, &spawn_cwd, size).await {
Ok(a) => a,
Err(e) => {
error!("failed to attach pty session {}: {}", key, e);
let (mut sender, _) = ws.split();
let msg = serde_json::to_string(&ServerControl::Error {
message: "failed to start terminal",
})
.unwrap();
let _ = sender.send(Message::Text(msg.into())).await;
return;
}
};
if attach.reconnected {
attach.state.vt.lock().unwrap().invalidate_diff();
}
let (mut ws_tx, mut ws_rx) = ws.split();
let attached_msg = serde_json::to_string(&ServerControl::Attached { session: &key }).unwrap();
if ws_tx.send(Message::Text(attached_msg.into())).await.is_err() {
return;
}
if !attach.replay.is_empty()
&& ws_tx.send(Message::Binary(attach.replay.clone().into())).await.is_err()
{
return;
}
let (pty_in_tx, mut pty_in_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(256);
let (viewport_tx, mut viewport_rx) =
tokio::sync::mpsc::channel::<(u32, Option<u64>)>(MAX_PENDING_VIEWPORT_REQUESTS);
let writer_attach = attach.clone();
let resize_attach = attach.clone();
let writer_key = key.clone();
std::thread::spawn(move || {
while let Some(data) = pty_in_rx.blocking_recv() {
if let Err(e) = writer_attach.write(&data) {
warn!("PTY write failed (pty session {writer_key}): {e}");
return;
}
}
debug!("PTY writer exited (pty session {writer_key})");
});
let (agent_tx, mut agent_rx) = tokio::sync::mpsc::channel::<String>(16);
let agent_handle: Option<tokio::task::JoinHandle<()>> = if hook_enabled {
let store = state.engines.pty_agent_events();
let push_key = key.clone();
Some(tokio::spawn(async move {
let mut rx = store.subscribe();
let mut last_nonce: Option<String> = None;
if let Some(snap) = store.fresh_snapshot(&push_key)
&& let Some(msg) = agent_state_frame(&snap, &mut last_nonce)
&& agent_tx.send(msg).await.is_err()
{
return;
}
loop {
if rx.changed().await.is_err() {
break;
}
let Some(snap) = store.fresh_snapshot(&push_key) else { continue };
if let Some(msg) = agent_state_frame(&snap, &mut last_nonce)
&& agent_tx.send(msg).await.is_err()
{
break;
}
}
}))
} else {
None
};
let cell_frame_enabled = Arc::new(AtomicBool::new(false));
let fwd_cfe = cell_frame_enabled.clone();
let session_id_for_frame = session_id.clone();
let encode_attach = attach.clone();
let mut rx = attach.rx;
let mut event_rx = attach.event_rx;
let mut forward_handle = tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(33));
let mut cell_frame_started = false;
let mut last_full_at = std::time::Instant::now();
let encode_now = |force_full: bool| {
let mut vt_guard = encode_attach.state.vt.lock().unwrap();
vt_guard.encode_cell_frame(&session_id_for_frame, force_full)
};
loop {
if fwd_cfe.load(Ordering::Relaxed) {
if !cell_frame_started {
cell_frame_started = true;
info!("cell_frame encoder started: session={session_id_for_frame}");
}
tokio::select! {
biased;
agent_msg = agent_rx.recv() => {
let Some(msg) = agent_msg else { break };
if ws_tx.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
event = event_rx.recv() => {
if let Ok(ev) = event {
let needs_overlay = matches!(ev, SemanticEvent::AltScreenEnter | SemanticEvent::AltScreenExit);
if needs_overlay {
let json = {
let mut vt_guard = encode_attach.state.vt.lock().unwrap();
let json = vt_guard.encode_overlay_frame(&session_id_for_frame);
vt_guard.invalidate_diff();
json
};
if ws_tx.send(Message::Text(json.into())).await.is_err() {
break;
}
}
}
}
viewport_y = viewport_rx.recv() => {
let Some((y, fp)) = viewport_y else { break };
trace!(y, "viewport request served: session={session_id_for_frame}");
let json = {
let vt_guard = encode_attach.state.vt.lock().unwrap();
vt_guard.encode_viewport_frame(&session_id_for_frame, y, fp)
};
if ws_tx.send(Message::Text(json.into())).await.is_err() {
break;
}
}
_ = rx.recv() => {
let json = encode_now(false);
if ws_tx.send(Message::Text(json.into())).await.is_err() {
break;
}
}
_ = ticker.tick() => {
let force_full = last_full_at.elapsed()
>= std::time::Duration::from_millis(FULL_FRAME_INTERVAL_MS);
if force_full {
last_full_at = std::time::Instant::now();
}
let t0 = std::time::Instant::now();
let json = encode_now(force_full);
let sent = ws_tx.send(Message::Text(json.into())).await;
let el = t0.elapsed().as_micros() as u64;
if el > SLOW_FRAME_US {
warn!(el, "slow tick frame: loop blocked while sending");
}
if sent.is_err() {
break;
}
}
}
} else {
tokio::select! {
out = rx.recv() => match out {
Ok(data) => {
if ws_tx.send(Message::Binary(data.into())).await.is_err() {
break;
}
}
Err(RecvError::Lagged(n)) => {
warn!("pty output lagged, dropped {n} frames");
continue;
}
Err(RecvError::Closed) => break,
},
agent_msg = agent_rx.recv() => {
let Some(msg) = agent_msg else { break };
if ws_tx.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
_ = ticker.tick() => {}
}
}
}
});
let read_cfe = cell_frame_enabled.clone();
let read_session_id = session_id.clone();
let mut read_handle = tokio::spawn(async move {
while let Some(msg) = ws_rx.next().await {
match msg {
#[allow(clippy::collapsible_match)]
Ok(Message::Binary(data)) => {
if pty_in_tx.send(data.to_vec()).await.is_err() {
break;
}
}
Ok(Message::Text(text)) => {
if let Ok(hello) = serde_json::from_str::<ClientHello>(&text) {
if hello.t == "hello" && hello.supports_cell_frame {
read_cfe.store(true, Ordering::Relaxed);
info!("cell_frame enabled: session={}", read_session_id);
}
continue;
}
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl {
ClientControl::Resize { cols, rows } => {
if cols > 0 && cols <= 1000 && rows > 0 && rows <= 1000 {
let new_size =
PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
if let Err(e) = resize_attach.resize(new_size) {
warn!("PTY resize failed (pty): {}", e);
}
}
}
ClientControl::Ping => {
debug!("ping received (pty)");
}
ClientControl::Resync => {
resize_attach.state.vt.lock().unwrap().invalidate_diff();
}
ClientControl::ViewportRequest { y, fp } => {
if read_cfe.load(Ordering::Relaxed) {
let y = y.max(0) as u32;
let fp = fp.as_deref().and_then(parse_anchor_fp);
if let Err(e) = viewport_tx.try_send((y, fp)) {
debug!(y, "viewport request dropped: {e}");
}
}
}
}
}
}
Ok(Message::Close(_)) => break,
Err(_) => break,
_ => {}
}
}
});
tokio::select! {
_ = &mut forward_handle => {
debug!("PTY→WS forward ended (pty): session={key}");
}
_ = &mut read_handle => {
debug!("WS→PTY read ended (pty): session={key}");
}
}
forward_handle.abort();
read_handle.abort();
if let Some(h) = agent_handle {
h.abort();
}
info!("terminal WS disconnected (pty): session={session_id} — 会话进程保持常驻");
}
fn parse_anchor_fp(fp: &str) -> Option<u64> {
u64::from_str_radix(fp, 16).ok()
}
fn agent_state_frame(
snap: &crate::agent::state::AgentSnapshot,
last_nonce: &mut Option<String>,
) -> Option<String> {
if snap.agent_nonce.is_some() && snap.agent_nonce == *last_nonce {
return None;
}
*last_nonce = snap.agent_nonce.clone();
serde_json::to_string(&ServerControl::AgentState {
agent_kind: Some(snap.agent_kind.as_str()),
state: snap.agent_state.as_str(),
attention_reason: snap.attention_reason.map(|r| r.as_str()),
agent_event: snap.agent_event.as_deref(),
agent_nonce: snap.agent_nonce.as_deref(),
})
.ok()
}