The TUI repainted on every 33ms tick whether or not anything had changed — measured at ~725 bytes/s of escape sequences with nothing attached and nothing happening. Over a remote terminal that is constant traffic for no visible change. Output was also unbuffered: `CrosstermBackend::new(std::io::stdout())` issues one `write!` per cell run, 3551 of them for a 200x50 repaint, each taking the global stdout lock. A BufWriter collapses a frame into one flush (measured: 3551 writes -> 2). Frames are now keyed on the catalog version, the active pane's screen revision, an input epoch and the terminal size, and skipped when the key is unchanged. Session cards carry an advancing duration, so a frame still never stands for longer than a second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
Rust
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|