This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/session_task.rs
T

280 lines
11 KiB
Rust

//! `SessionTask` — long-running Tokio task that owns one session's WS to the
//! daemon's `WS /api/attach/:id` and feeds bytes into a `vt100::Parser`.
//!
//! The parser's screen lives in `Arc<Mutex<vt100::Parser>>` so the render loop
//! can read it from any thread. The handle returned by [`SessionTask::spawn`]
//! is cheap to clone and is what the rest of the TUI carries around.
//!
//! Background liveness:
//! - The task stays alive even when no pane currently renders the session.
//! This is what makes "switch tabs and the other session's output keeps
//! flowing" work — the screen is up to date the moment focus returns.
//! - On WS drop the task transitions to `Reconnecting` and retries with the
//! same exponential backoff as the catalog (1s → 30s, ±25% jitter). The
//! parser is **not** reset on reconnect — the last-known frame stays
//! visible until new bytes arrive, so the user doesn't see a blank.
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::ClientControl;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
/// Match the browser terminal's retained history without letting long-running
/// sessions grow the client without bound.
pub const SCROLLBACK_ROWS: usize = 5_000;
/// Input the dispatcher (or the resize tracker) pushes to a session task.
#[derive(Debug, Clone)]
pub enum SessionInput {
/// Raw bytes to send on the attach WS as a binary frame (forwarded
/// keystrokes, paste content, etc.).
Bytes(Vec<u8>),
/// Per-session resize. The daemon resizes its PTY in response.
Resize { cols: u16, rows: u16 },
}
/// Lifecycle state of the underlying WS connection. The renderer reads this
/// to draw a `reconnecting…` overlay or freeze the screen on `Ended`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum SessionState {
Connecting = 0,
Live = 1,
Reconnecting = 2,
Ended = 3,
}
/// Cheap-to-clone handle returned by [`SessionTask::spawn`]. Holds:
///
/// - `tx` — `mpsc::Sender<SessionInput>`: the dispatcher pushes keystrokes
/// and resizes here.
/// - `screen` — `Arc<Mutex<vt100::Parser>>`: the renderer reads cells from
/// here.
/// - `state` — `Arc<AtomicU8>`: live status of the WS, queryable without
/// holding the screen lock.
/// - `revision` — `Arc<AtomicU64>`: bumped every time the parser's screen is
/// mutated. The render loop compares it against the last drawn frame's so an
/// idle pane costs a `load` instead of a repaint.
#[derive(Clone)]
pub struct SessionHandle {
pub tx: mpsc::Sender<SessionInput>,
pub screen: Arc<Mutex<vt100::Parser>>,
pub(crate) state: Arc<AtomicU8>,
pub(crate) revision: Arc<AtomicU64>,
}
impl std::fmt::Debug for SessionHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionHandle")
.field("state", &self.state())
.finish_non_exhaustive()
}
}
impl SessionHandle {
pub fn state(&self) -> SessionState {
match self.state.load(Ordering::Acquire) {
0 => SessionState::Connecting,
1 => SessionState::Live,
2 => SessionState::Reconnecting,
_ => SessionState::Ended,
}
}
/// Monotonic counter of screen mutations. Only equality across two reads
/// is meaningful — the loop uses it to decide whether a frame is stale.
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
/// Stop the underlying task. Used when the pane closes and the user has
/// no other tabs pointing at this session.
pub fn end(&self) {
self.state
.store(SessionState::Ended as u8, Ordering::Release);
}
}
#[derive(Debug, thiserror::Error)]
pub enum SessionTaskError {
#[error("ws connect failed: {0}")]
ConnectFailed(#[from] tokio_tungstenite::tungstenite::Error),
}
#[derive(Debug)]
pub struct SessionTask;
impl SessionTask {
/// Spawn the background task. Returns immediately with a handle; the WS
/// connect happens asynchronously. The handle's `state()` reports
/// `Connecting` until the WS handshake succeeds.
///
/// `rows`/`cols` are the **initial** size hint — the renderer will follow
/// up with `SessionInput::Resize` on every frame whose allocation changes.
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
let revision = Arc::new(AtomicU64::new(0));
let parser_bg = parser.clone();
let state_bg = state.clone();
let revision_bg = revision.clone();
tokio::spawn(async move {
let url = ws_url;
let mut backoff = Duration::from_secs(1);
loop {
if state_bg.load(Ordering::Acquire) == SessionState::Ended as u8 {
return;
}
state_bg.store(SessionState::Connecting as u8, Ordering::Release);
let (ws, _resp) = match tokio_tungstenite::connect_async(&url).await {
Ok(x) => x,
Err(e) => {
tracing::debug!(error = ?e, "session: connect failed");
state_bg.store(SessionState::Reconnecting as u8, Ordering::Release);
tokio::time::sleep(jitter(backoff)).await;
backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30));
continue;
}
};
backoff = Duration::from_secs(1);
state_bg.store(SessionState::Live as u8, Ordering::Release);
let (mut sink, mut stream) = ws.split();
// Pump until the WS closes or the sender side errors.
loop {
tokio::select! {
biased;
Some(inp) = rx.recv() => {
match inp {
SessionInput::Bytes(b) => {
if sink.send(Message::Binary(b)).await.is_err() {
break;
}
}
SessionInput::Resize { cols, rows } => {
parser_bg.lock().expect("vt100 parser mutex").set_size(rows, cols);
revision_bg.fetch_add(1, Ordering::Release);
let frame = serde_json::to_string(&ClientControl::Resize {
cols,
rows,
})
.expect("serialize resize");
if sink.send(Message::Text(frame)).await.is_err() {
break;
}
}
}
}
frame = stream.next() => {
match frame {
Some(Ok(Message::Binary(bytes))) => {
parser_bg.lock().expect("vt100 parser mutex").process(&bytes);
revision_bg.fetch_add(1, Ordering::Release);
}
Some(Ok(Message::Text(text))) => {
// Ping/Pong control frames travel as text JSON. Reply to Ping.
if let Ok(ClientControl::Ping) =
serde_json::from_str::<ClientControl>(&text)
{
let pong = serde_json::to_string(&ClientControl::Pong)
.expect("serialize pong");
if sink.send(Message::Text(pong)).await.is_err() {
break;
}
}
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(e)) => {
tracing::debug!(error = ?e, "session: ws error");
break;
}
_ => {}
}
}
}
}
// Disconnected. Keep the last-known parser screen intact;
// reconnect after a backoff.
state_bg.store(SessionState::Reconnecting as u8, Ordering::Release);
tokio::time::sleep(jitter(backoff)).await;
backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30));
}
});
SessionHandle {
tx,
screen: parser,
state,
revision,
}
}
}
pub(crate) fn jitter(base: Duration) -> Duration {
let ms = base.as_millis() as u64;
let now_nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0) as u64;
let delta = (now_nanos % 500) as i64 - 250;
let scaled = (ms as i64).saturating_mul(delta).div_euclid(1000);
let total = ms.saturating_add_signed(scaled);
Duration::from_millis(total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_constructs_as_connecting() {
let h = SessionHandle {
tx: mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Connecting as u8)),
revision: Arc::new(AtomicU64::new(0)),
};
assert_eq!(h.state(), SessionState::Connecting);
}
#[test]
fn end_transitions_to_ended() {
let h = SessionHandle {
tx: mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
};
h.end();
assert_eq!(h.state(), SessionState::Ended);
}
#[test]
fn revision_starts_at_zero_and_tracks_screen_mutations() {
let revision = Arc::new(AtomicU64::new(0));
let h = SessionHandle {
tx: mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: revision.clone(),
};
assert_eq!(h.revision(), 0);
// Stands in for the WS pump: process bytes, then bump.
h.screen.lock().unwrap().process(b"hello");
revision.fetch_add(1, Ordering::Release);
assert_eq!(h.revision(), 1);
}
}