tracks term then restore with vt100

This commit is contained in:
lda
2026-07-15 21:37:17 +07:00 Verified
parent 1bfb1c17d7
commit eaa77fa54e
7 changed files with 286 additions and 149 deletions
Generated
+29
View File
@@ -97,6 +97,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -3606,6 +3612,27 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]] [[package]]
name = "wakey" name = "wakey"
version = "0.3.1" version = "0.3.1"
@@ -3646,7 +3673,9 @@ dependencies = [
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"unicode-width",
"url", "url",
"vt100",
"wakey", "wakey",
"wakey-core", "wakey-core",
] ]
+8 -8
View File
@@ -54,13 +54,13 @@ The terminal control vocabulary is deliberately small:
```text ```text
operator -> agent: resize, close operator -> agent: resize, close
control plane -> agent: refresh control plane -> agent: snapshot
agent -> operator: ready, exited, error agent -> operator: ready, exited, error
``` ```
Initial terminal dimensions are part of session attachment. Resize carries rows and columns; pixel dimensions may be added later if a demonstrated program requires them. Initial terminal dimensions are part of session attachment. Resize carries rows and columns; pixel dimensions may be added later if a demonstrated program requires them.
Terminal behavior such as Ctrl-C, Ctrl-Z, Ctrl-D, cursor keys, mouse reporting, bracketed paste, colors, and terminal-title changes remains in the byte stream. The control plane does not parse ANSI escape sequences. The browser terminal emulator renders output and owns connected-session scrollback. Agent PTYs advertise `TERM=xterm-256color` and `COLORTERM=truecolor` because xterm.js is the actual frontend; applications may therefore negotiate xterm mouse reporting without a Wakey-specific mouse protocol. Terminal behavior such as Ctrl-C, Ctrl-Z, Ctrl-D, cursor keys, mouse reporting, bracketed paste, colors, and terminal-title changes remains in the byte stream. The control plane does not parse ANSI escape sequences. The browser terminal emulator renders output and owns connected-session scrollback. The agent also feeds PTY output into a `vt100` parser so it can reconstruct the current visible screen and terminal input modes after detachment. Agent PTYs advertise `TERM=xterm-256color` and `COLORTERM=truecolor` because xterm.js is the actual frontend; applications may therefore negotiate xterm mouse reporting without a Wakey-specific mouse protocol.
WebSockets already provide ordered, reliable delivery, so terminal frames do not carry sequence numbers in the initial protocol. WebSockets already provide ordered, reliable delivery, so terminal frames do not carry sequence numbers in the initial protocol.
@@ -68,13 +68,13 @@ WebSockets already provide ordered, reliable delivery, so terminal frames do not
Terminal sessions allow only one attached operator. Their lifetime belongs to the agent process, not to a browser route or a particular control-plane connection. Terminal sessions allow only one attached operator. Their lifetime belongs to the agent process, not to a browser route or a particular control-plane connection.
When the operator socket disconnects, the session becomes detached. Navigation, browser closure, and network loss do not terminate the PTY. A protected operator request may discover the live session and obtain a fresh, single-use attachment credential. The control plane retains only a bounded in-memory output buffer during the gap and replays it before live output on reattachment. When the operator socket disconnects, the session becomes detached. Navigation, browser closure, and network loss do not terminate the PTY. A protected operator request may discover the live session and obtain a fresh, single-use attachment credential. The control plane does not retain terminal output.
The operator UI lists live sessions as tabs and stores only the active terminal ID in browser-tab-scoped `sessionStorage`. On reload it prefers that ID and briefly waits for the previous WebSocket to detach before requesting a fresh attachment credential. The stored ID is a navigation hint, not an ownership credential; another attached operator still locks the session at the control plane. The operator UI lists live sessions as tabs and stores only the active terminal ID in browser-tab-scoped `sessionStorage`. On reload it prefers that ID and briefly waits for the previous WebSocket to detach before requesting a fresh attachment credential. The stored ID is a navigation hint, not an ownership credential; another attached operator still locks the session at the control plane.
After operator attachment, the control plane sends a `refresh` hint. The agent queries the PTY's current foreground process group and sends it `SIGWINCH`, allowing full-screen applications to redraw without assuming that the original shell remains in the foreground. Refresh failure is logged but never terminates the session. After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 5,000 retained physical rows followed by its parsed current screen and input modes as terminal escape bytes, sends them before subsequent live output, and then signals the PTY's current foreground process group with `SIGWINCH`. Historical rows retain their terminal attributes and are replayed as ordinary terminal output so xterm builds its native scrollback; the final formatted state restores the exact live screen. The signal lets full-screen applications redraw without assuming that the original shell remains in the foreground. Signal failure is logged but never terminates the session.
The agent keeps its terminal manager outside the control-WebSocket reconnect loop. If a dedicated terminal relay disconnects, the PTY continues draining output into a bounded local replay buffer while waiting for replacement relay credentials. On every authenticated control connection, the agent reports its in-memory live terminal IDs and creation times. The control plane reconciles that inventory, adopts sessions missing from its volatile registry, and issues fresh relay credentials. This allows sessions to survive control-plane restart without persisting terminal state in SQLite. The agent keeps its terminal manager outside the control-WebSocket reconnect loop. If a dedicated terminal relay disconnects, the PTY continues draining output into the agent-owned terminal parser while waiting for replacement relay credentials. On every authenticated control connection, the agent reports its in-memory live terminal IDs and creation times. The control plane reconciles that inventory, adopts sessions missing from its volatile registry, and issues fresh relay credentials. This allows sessions to survive control-plane restart without persisting terminal state in SQLite.
Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session. Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session.
@@ -86,16 +86,16 @@ The agent reports a normal exit status or terminating signal when available. The
Terminal transport must not use unbounded queues. Terminal transport must not use unbounded queues.
Every queue between PTY, agent socket, control-plane relay, and browser socket is bounded. Agent and control-plane replay buffers are bounded as well. The agent keeps draining the PTY while detached so a noisy child cannot stall the agent; output older than the replay bound is discarded. Every queue between PTY, agent socket, control-plane relay, and browser socket is bounded. The agent keeps draining and parsing the PTY while detached so a noisy child cannot stall the agent. Parsed state is bounded by the configured terminal dimensions and a 5,000-row scrollback limit. The control plane retains only a bounded queue of operator controls while an agent relay is reconnecting.
When input/control and output are ready simultaneously, relay loops prioritize input and control. This keeps interrupt, resize, close, and refresh traffic responsive while commands produce sustained output. When input/control and output are ready simultaneously, relay loops prioritize input and control. This keeps interrupt, resize, close, and snapshot traffic responsive while commands produce sustained output.
The implementation also enforces: The implementation also enforces:
- maximum terminal frame size; - maximum terminal frame size;
- per-agent concurrent-session limits; - per-agent concurrent-session limits;
- idle and absolute session timeouts; - idle and absolute session timeouts;
- bounded detached-session replay; - bounded parsed terminal state;
- bounded control-plane relay buffers; and - bounded control-plane relay buffers; and
- cleanup when any relay task exits unexpectedly. - cleanup when any relay task exits unexpectedly.
+2
View File
@@ -39,5 +39,7 @@ tracing-subscriber = { version = "0.3", features = [
] } ] }
time = { version = "0.3", features = ["formatting", "local-offset"] } time = { version = "0.3", features = ["formatting", "local-offset"] }
url = "2" url = "2"
unicode-width = "0.2"
vt100 = "0.16.2"
wakey = { path = "..", registry = "gitea", version = "0" } wakey = { path = "..", registry = "gitea", version = "0" }
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" } wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
+4 -3
View File
@@ -57,7 +57,7 @@ pub struct AgentTerminalSession {
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum TerminalControl { pub enum TerminalControl {
Resize { rows: u16, cols: u16 }, Resize { rows: u16, cols: u16 },
Refresh, Snapshot,
Ready, Ready,
Exited { exit_code: Option<i32> }, Exited { exit_code: Option<i32> },
Error { code: String, message: String }, Error { code: String, message: String },
@@ -336,8 +336,9 @@ mod tests {
}; };
let json = serde_json::to_string(&resize).expect("serialize resize"); let json = serde_json::to_string(&resize).expect("serialize resize");
assert_eq!(json, r#"{"type":"resize","rows":40,"cols":160}"#); assert_eq!(json, r#"{"type":"resize","rows":40,"cols":160}"#);
let refresh = serde_json::to_string(&TerminalControl::Refresh).expect("serialize refresh"); let snapshot =
assert_eq!(refresh, r#"{"type":"refresh"}"#); serde_json::to_string(&TerminalControl::Snapshot).expect("serialize snapshot");
assert_eq!(snapshot, r#"{"type":"snapshot"}"#);
let inventory = ClientMessage::TerminalSessions { let inventory = ClientMessage::TerminalSessions {
sessions: vec![AgentTerminalSession { sessions: vec![AgentTerminalSession {
+216 -79
View File
@@ -1,4 +1,4 @@
use std::collections::{HashMap, VecDeque}; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex, Weak}; use std::sync::{Arc, Mutex, Weak};
use std::time::Duration; use std::time::Duration;
@@ -11,11 +11,95 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tracing::{info, warn}; use tracing::{info, warn};
use unicode_width::UnicodeWidthStr;
const MAX_TERMINAL_FRAME_BYTES: usize = 64 * 1024; const MAX_TERMINAL_FRAME_BYTES: usize = 64 * 1024;
const TERMINAL_REPLAY_BYTES: usize = 256 * 1024; const TERMINAL_SCROLLBACK_ROWS: usize = 5_000;
const PROCESS_SIGNAL_GRACE: Duration = Duration::from_secs(1); const PROCESS_SIGNAL_GRACE: Duration = Duration::from_secs(1);
/// Tracks the terminal's current rendered state while the browser is detached.
///
/// A snapshot is terminal escape output, so the browser can restore the screen
/// through its normal parser without a second state protocol.
struct TerminalState {
parser: vt100::Parser,
}
impl TerminalState {
fn new(rows: u16, cols: u16) -> Self {
Self {
parser: vt100::Parser::new(rows, cols, TERMINAL_SCROLLBACK_ROWS),
}
}
fn process(&mut self, bytes: &[u8]) {
self.parser.process(bytes);
}
fn resize(&mut self, rows: u16, cols: u16) {
self.parser.screen_mut().set_size(rows, cols);
}
fn snapshot(&mut self) -> Vec<u8> {
let screen = self.parser.screen_mut();
let (rows, cols) = screen.size();
let alternate_screen = screen.alternate_screen();
let mut physical_rows = Vec::new();
// set_scrollback changes the viewport exposed by Screen. Walking its
// top row from the maximum offset down to zero yields every retained
// physical row exactly once, oldest first.
screen.set_scrollback(usize::MAX);
let retained_rows = screen.scrollback();
for offset in (1..=retained_rows).rev() {
screen.set_scrollback(offset);
let contents = screen.rows(0, cols).next().unwrap_or_default();
physical_rows.push((
screen.rows_formatted(0, cols).next().unwrap_or_default(),
UnicodeWidthStr::width(contents.as_str()).min(usize::from(cols)),
screen.row_wrapped(0),
));
}
screen.set_scrollback(0);
physical_rows.extend((0..rows).map(|row| {
let contents = screen
.rows(0, cols)
.nth(usize::from(row))
.unwrap_or_default();
(
screen
.rows_formatted(0, cols)
.nth(usize::from(row))
.unwrap_or_default(),
UnicodeWidthStr::width(contents.as_str()).min(usize::from(cols)),
screen.row_wrapped(row),
)
}));
// Replace browser history, then stream physical rows so xterm builds
// its own scrollback. The final formatted state restores colors,
// cursor position, and input modes for the live screen.
let mut snapshot = b"\x1b[?1049l\x1b[3J\x1b[2J\x1b[H".to_vec();
for (index, (contents, display_width, wrapped)) in physical_rows.iter().enumerate() {
// rows_formatted encodes each row relative to default attributes.
// Reset between rows so attributes cannot leak across boundaries.
snapshot.extend_from_slice(b"\x1b[0m");
snapshot.extend_from_slice(contents);
snapshot.extend_from_slice(b"\x1b[0m");
if *wrapped {
snapshot.resize(snapshot.len() + usize::from(cols) - display_width, b' ');
} else if index + 1 < physical_rows.len() {
snapshot.extend_from_slice(b"\r\n");
}
}
if alternate_screen {
snapshot.extend_from_slice(b"\x1b[?1049h");
}
snapshot.extend_from_slice(&screen.state_formatted());
snapshot
}
}
struct ActiveTerminal { struct ActiveTerminal {
cancel: oneshot::Sender<()>, cancel: oneshot::Sender<()>,
relay_credentials: mpsc::UnboundedSender<String>, relay_credentials: mpsc::UnboundedSender<String>,
@@ -186,8 +270,7 @@ async fn run_terminal(
let mut relay_output: Option<mpsc::Sender<Message>> = None; let mut relay_output: Option<mpsc::Sender<Message>> = None;
let mut relay_task: Option<tokio::task::JoinHandle<()>> = None; let mut relay_task: Option<tokio::task::JoinHandle<()>> = None;
let mut relay_generation = 0_u64; let mut relay_generation = 0_u64;
let mut replay = VecDeque::new(); let mut terminal_state = TerminalState::new(rows, cols);
let mut replay_bytes = 0_usize;
let mut output = [0_u8; 16 * 1024]; let mut output = [0_u8; 16 * 1024];
let mut requested_close = false; let mut requested_close = false;
let mut observed_status = None; let mut observed_status = None;
@@ -214,8 +297,10 @@ async fn run_terminal(
RelayInput::Resize { generation, rows, cols } if generation == relay_generation => { RelayInput::Resize { generation, rows, cols } if generation == relay_generation => {
validate_size(rows, cols)?; validate_size(rows, cols)?;
writer.resize(rows, cols)?; writer.resize(rows, cols)?;
terminal_state.resize(rows, cols);
} }
RelayInput::Refresh { generation } if generation == relay_generation => { RelayInput::Snapshot { generation } if generation == relay_generation => {
send_terminal_snapshot(terminal_state.snapshot(), &mut relay_output).await;
if let Err(err) = writer.refresh() { if let Err(err) = writer.refresh() {
warn!(terminal_id = %terminal_id, error = %err, "terminal redraw signal failed"); warn!(terminal_id = %terminal_id, error = %err, "terminal redraw signal failed");
} }
@@ -225,14 +310,7 @@ async fn run_terminal(
break; break;
} }
RelayInput::Connected { generation, output } if generation == relay_generation => { RelayInput::Connected { generation, output } if generation == relay_generation => {
relay_output = Some(output.clone()); relay_output = Some(output);
while let Some(frame) = replay.pop_front() {
replay_bytes = replay_bytes.saturating_sub(message_size(&frame));
if output.send(frame).await.is_err() {
relay_output = None;
break;
}
}
} }
RelayInput::Disconnected { generation } if generation == relay_generation => { RelayInput::Disconnected { generation } if generation == relay_generation => {
relay_output = None; relay_output = None;
@@ -249,8 +327,6 @@ async fn run_terminal(
let generation = relay_generation; let generation = relay_generation;
let (output_tx, output_rx) = mpsc::channel(32); let (output_tx, output_rx) = mpsc::channel(32);
relay_output = None; relay_output = None;
let initial_replay = replay.drain(..).collect();
replay_bytes = 0;
let config = config.clone(); let config = config.clone();
let terminal_id = terminal_id.clone(); let terminal_id = terminal_id.clone();
let relay_input_tx = relay_input_tx.clone(); let relay_input_tx = relay_input_tx.clone();
@@ -260,7 +336,6 @@ async fn run_terminal(
terminal_id: terminal_id.clone(), terminal_id: terminal_id.clone(),
relay_token, relay_token,
generation, generation,
initial_replay,
output_tx, output_tx,
output_rx, output_rx,
input: relay_input_tx.clone(), input: relay_input_tx.clone(),
@@ -273,12 +348,13 @@ async fn run_terminal(
read = reader.read(&mut output) => { read = reader.read(&mut output) => {
match read { match read {
Ok(0) => break, Ok(0) => break,
Ok(count) => send_terminal_output( Ok(count) => {
Message::Binary(output[..count].to_vec().into()), terminal_state.process(&output[..count]);
&mut relay_output, send_terminal_output(
&mut replay, Message::Binary(output[..count].to_vec().into()),
&mut replay_bytes, &mut relay_output,
).await, ).await;
}
// Linux PTY masters commonly report EIO after the slave closes. // Linux PTY masters commonly report EIO after the slave closes.
Err(err) if err.raw_os_error() == Some(5) => break, Err(err) if err.raw_os_error() == Some(5) => break,
Err(err) => return Err(err).context("failed to read PTY output"), Err(err) => return Err(err).context("failed to read PTY output"),
@@ -317,7 +393,7 @@ enum RelayInput {
rows: u16, rows: u16,
cols: u16, cols: u16,
}, },
Refresh { Snapshot {
generation: u64, generation: u64,
}, },
Close { Close {
@@ -334,7 +410,6 @@ struct RelayConnection {
terminal_id: TerminalId, terminal_id: TerminalId,
relay_token: String, relay_token: String,
generation: u64, generation: u64,
initial_replay: Vec<Message>,
output_tx: mpsc::Sender<Message>, output_tx: mpsc::Sender<Message>,
output_rx: mpsc::Receiver<Message>, output_rx: mpsc::Receiver<Message>,
input: mpsc::UnboundedSender<RelayInput>, input: mpsc::UnboundedSender<RelayInput>,
@@ -356,11 +431,6 @@ async fn run_terminal_relay(relay: RelayConnection) -> Result<()> {
) )
.await?; .await?;
send_json(&mut sink, &TerminalControl::Ready).await?; send_json(&mut sink, &TerminalControl::Ready).await?;
for frame in relay.initial_replay {
sink.send(frame)
.await
.context("failed to replay detached terminal output")?;
}
relay relay
.input .input
.send(RelayInput::Connected { .send(RelayInput::Connected {
@@ -393,8 +463,8 @@ async fn run_terminal_relay(relay: RelayConnection) -> Result<()> {
}) })
.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?; .map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
} }
TerminalControl::Refresh => { TerminalControl::Snapshot => {
relay.input.send(RelayInput::Refresh { relay.input.send(RelayInput::Snapshot {
generation: relay.generation, generation: relay.generation,
}).map_err(|_| anyhow::anyhow!("terminal worker stopped"))?; }).map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
} }
@@ -423,39 +493,27 @@ async fn run_terminal_relay(relay: RelayConnection) -> Result<()> {
Ok(()) Ok(())
} }
async fn send_terminal_output( async fn send_terminal_output(frame: Message, relay: &mut Option<mpsc::Sender<Message>>) {
frame: Message, if let Some(tx) = relay.as_ref()
relay: &mut Option<mpsc::Sender<Message>>, && tx.send(frame).await.is_err()
replay: &mut VecDeque<Message>, {
replay_bytes: &mut usize, *relay = None;
) { }
if let Some(tx) = relay.as_ref() { }
if let Err(error) = tx.send(frame).await {
async fn send_terminal_snapshot(snapshot: Vec<u8>, relay: &mut Option<mpsc::Sender<Message>>) {
let Some(tx) = relay.as_ref() else {
return;
};
for chunk in snapshot.chunks(MAX_TERMINAL_FRAME_BYTES) {
if tx
.send(Message::Binary(chunk.to_vec().into()))
.await
.is_err()
{
*relay = None; *relay = None;
push_local_replay(error.0, replay, replay_bytes); return;
} }
} else {
push_local_replay(frame, replay, replay_bytes);
}
}
fn push_local_replay(frame: Message, replay: &mut VecDeque<Message>, replay_bytes: &mut usize) {
*replay_bytes += message_size(&frame);
replay.push_back(frame);
while *replay_bytes > TERMINAL_REPLAY_BYTES {
if let Some(dropped) = replay.pop_front() {
*replay_bytes = replay_bytes.saturating_sub(message_size(&dropped));
} else {
break;
}
}
}
fn message_size(message: &Message) -> usize {
match message {
Message::Text(text) => text.len(),
Message::Binary(bytes) | Message::Ping(bytes) | Message::Pong(bytes) => bytes.len(),
Message::Close(_) | Message::Frame(_) => 0,
} }
} }
@@ -558,25 +616,104 @@ mod tests {
} }
#[test] #[test]
fn detached_replay_drops_oldest_output_at_bound() { fn snapshot_reconstructs_screen_content_and_cursor() {
let mut replay = VecDeque::new(); let mut state = TerminalState::new(24, 80);
let mut replay_bytes = 0; state.process(b"hello\r\n\x1b[31mred\x1b[0m\x1b[10;20Hcursor");
for marker in 0_u8..10 {
push_local_replay( let mut restored = vt100::Parser::new(24, 80, 0);
Message::Binary(vec![marker; TERMINAL_REPLAY_BYTES / 4].into()), restored.process(b"stale browser contents\x1b[24;80Hjunk");
&mut replay, restored.process(&state.snapshot());
&mut replay_bytes,
);
}
assert!(replay_bytes <= TERMINAL_REPLAY_BYTES);
assert_eq!(replay.len(), 4);
assert_eq!( assert_eq!(
replay.front().and_then(|frame| match frame { restored.screen().contents(),
Message::Binary(bytes) => bytes.first().copied(), state.parser.screen().contents()
_ => None, );
}), assert_eq!(
Some(6) restored.screen().cursor_position(),
state.parser.screen().cursor_position()
); );
} }
#[test]
fn snapshot_reconstructs_terminal_input_modes() {
let mut state = TerminalState::new(24, 80);
state.process(b"\x1b[?1h\x1b[?1000h\x1b[?2004h");
let mut restored = vt100::Parser::new(24, 80, 0);
restored.process(&state.snapshot());
assert!(restored.screen().application_cursor());
assert!(restored.screen().bracketed_paste());
assert_eq!(
restored.screen().mouse_protocol_mode(),
state.parser.screen().mouse_protocol_mode()
);
}
#[test]
fn terminal_state_tracks_resize() {
let mut state = TerminalState::new(24, 80);
state.resize(40, 120);
assert_eq!(state.parser.screen().size(), (40, 120));
}
#[test]
fn snapshot_reconstructs_agent_scrollback() {
let mut state = TerminalState::new(3, 20);
for line in 0..10 {
if line == 0 {
state.process(b"\x1b[31mhistory 0\x1b[0m\r\n");
} else {
state.process(format!("history {line}\r\n").as_bytes());
}
}
let expected_screen = state.parser.screen().contents();
let mut restored = vt100::Parser::new(3, 20, TERMINAL_SCROLLBACK_ROWS);
restored.process(&state.snapshot());
assert_eq!(restored.screen().contents(), expected_screen);
restored.screen_mut().set_scrollback(usize::MAX);
assert!(restored.screen().scrollback() >= 8);
assert!(restored.screen().contents().contains("history 0"));
assert_eq!(
restored
.screen()
.cell(0, 0)
.expect("first history cell")
.fgcolor(),
vt100::Color::Idx(1)
);
}
#[test]
fn snapshot_restores_alternate_screen_mode() {
let mut state = TerminalState::new(3, 20);
state.process(b"shell\r\n\x1b[?1049hfull-screen");
let mut restored = vt100::Parser::new(3, 20, TERMINAL_SCROLLBACK_ROWS);
restored.process(&state.snapshot());
assert!(restored.screen().alternate_screen());
assert_eq!(restored.screen().contents(), "full-screen");
}
#[tokio::test]
async fn snapshot_is_split_at_the_terminal_frame_limit() {
let snapshot = vec![7; MAX_TERMINAL_FRAME_BYTES * 2 + 1];
let (tx, mut rx) = mpsc::channel(3);
let mut relay = Some(tx);
send_terminal_snapshot(snapshot.clone(), &mut relay).await;
let mut restored = Vec::new();
for _ in 0..3 {
let Message::Binary(chunk) = rx.recv().await.expect("snapshot chunk") else {
panic!("snapshot chunks must be binary");
};
assert!(chunk.len() <= MAX_TERMINAL_FRAME_BYTES);
restored.extend_from_slice(&chunk);
}
assert_eq!(restored, snapshot);
}
} }
+7 -13
View File
@@ -321,7 +321,7 @@ async fn handle_operator_terminal_socket(
return; return;
} }
}; };
let (mut outbound, replay) = match state let mut outbound = match state
.terminals .terminals
.attach_operator(&terminal_id, &attachment_token) .attach_operator(&terminal_id, &attachment_token)
.await .await
@@ -333,14 +333,14 @@ async fn handle_operator_terminal_socket(
} }
}; };
info!(terminal_id, "operator terminal socket attached"); info!(terminal_id, "operator terminal socket attached");
let refresh = serde_json::to_string(&TerminalControl::Refresh) let snapshot = serde_json::to_string(&TerminalControl::Snapshot)
.expect("terminal refresh control serializes"); .expect("terminal snapshot control serializes");
if let Err(code) = state if let Err(code) = state
.terminals .terminals
.relay_to_agent(&terminal_id, TerminalRelayFrame::Text(refresh)) .relay_to_agent(&terminal_id, TerminalRelayFrame::Text(snapshot))
.await .await
{ {
warn!(terminal_id, code, "failed to request terminal redraw"); warn!(terminal_id, code, "failed to request terminal snapshot");
} }
let summary = state.terminals.summary(&terminal_id).await; let summary = state.terminals.summary(&terminal_id).await;
if let Some((agent_id, _, _, _)) = &summary { if let Some((agent_id, _, _, _)) = &summary {
@@ -371,12 +371,6 @@ async fn handle_operator_terminal_socket(
return; return;
} }
} }
for frame in replay {
if send_relay_frame(&mut write, frame).await.is_err() {
return;
}
}
let mut explicit_close = false; let mut explicit_close = false;
loop { loop {
tokio::select! { tokio::select! {
@@ -657,7 +651,7 @@ mod tests {
let ready = serde_json::to_string(&TerminalControl::Ready).expect("ready json"); let ready = serde_json::to_string(&TerminalControl::Ready).expect("ready json");
assert!(operator_relay_frame(Message::Text(ready.into())).is_err()); assert!(operator_relay_frame(Message::Text(ready.into())).is_err());
let refresh = serde_json::to_string(&TerminalControl::Refresh).expect("refresh json"); let snapshot = serde_json::to_string(&TerminalControl::Snapshot).expect("snapshot json");
assert!(operator_relay_frame(Message::Text(refresh.into())).is_err()); assert!(operator_relay_frame(Message::Text(snapshot.into())).is_err());
} }
} }
+20 -46
View File
@@ -8,7 +8,7 @@ use wakey_agent::protocol::{AgentTerminalSession, TerminalId};
pub const TERMINAL_RELAY_QUEUE: usize = 32; pub const TERMINAL_RELAY_QUEUE: usize = 32;
pub const TERMINAL_MAX_FRAME_BYTES: usize = 64 * 1024; pub const TERMINAL_MAX_FRAME_BYTES: usize = 64 * 1024;
pub const TERMINAL_REPLAY_BYTES: usize = 256 * 1024; pub const TERMINAL_PENDING_AGENT_BYTES: usize = 256 * 1024;
pub const TERMINAL_MAX_SESSIONS_PER_AGENT: usize = 2; pub const TERMINAL_MAX_SESSIONS_PER_AGENT: usize = 2;
pub const TERMINAL_ATTACH_TIMEOUT: Duration = Duration::from_secs(10); pub const TERMINAL_ATTACH_TIMEOUT: Duration = Duration::from_secs(10);
pub const TERMINAL_ABSOLUTE_TIMEOUT: Duration = Duration::from_secs(12 * 60 * 60); pub const TERMINAL_ABSOLUTE_TIMEOUT: Duration = Duration::from_secs(12 * 60 * 60);
@@ -40,8 +40,6 @@ struct TerminalSession {
pending_agent_bytes: usize, pending_agent_bytes: usize,
operator_tx: Option<mpsc::Sender<TerminalRelayFrame>>, operator_tx: Option<mpsc::Sender<TerminalRelayFrame>>,
operator_detached_at: Option<Instant>, operator_detached_at: Option<Instant>,
replay: VecDeque<TerminalRelayFrame>,
replay_bytes: usize,
} }
pub struct CreatedTerminal { pub struct CreatedTerminal {
@@ -106,8 +104,6 @@ impl TerminalRegistry {
pending_agent_bytes: 0, pending_agent_bytes: 0,
operator_tx: None, operator_tx: None,
operator_detached_at: None, operator_detached_at: None,
replay: VecDeque::new(),
replay_bytes: 0,
}, },
); );
@@ -202,8 +198,6 @@ impl TerminalRegistry {
pending_agent_bytes: 0, pending_agent_bytes: 0,
operator_tx: None, operator_tx: None,
operator_detached_at: Some(Instant::now()), operator_detached_at: Some(Instant::now()),
replay: VecDeque::new(),
replay_bytes: 0,
}); });
if session.agent_id != agent_id || session.agent_tx.is_some() { if session.agent_id != agent_id || session.agent_tx.is_some() {
continue; continue;
@@ -264,7 +258,7 @@ impl TerminalRegistry {
&self, &self,
terminal_id: &str, terminal_id: &str,
attachment_token: &str, attachment_token: &str,
) -> Result<(mpsc::Receiver<TerminalRelayFrame>, Vec<TerminalRelayFrame>), &'static str> { ) -> Result<mpsc::Receiver<TerminalRelayFrame>, &'static str> {
let mut sessions = self.inner.lock().await; let mut sessions = self.inner.lock().await;
let session = active_session(&mut sessions, terminal_id)?; let session = active_session(&mut sessions, terminal_id)?;
if session.operator_tx.is_some() { if session.operator_tx.is_some() {
@@ -275,12 +269,9 @@ impl TerminalRegistry {
} }
session.attachment_token = None; session.attachment_token = None;
session.operator_detached_at = None; session.operator_detached_at = None;
// Keep the rolling transcript after attachment so a newly mounted
// browser can reconstruct recent terminal state.
let replay = session.replay.iter().cloned().collect();
let (tx, rx) = mpsc::channel(TERMINAL_RELAY_QUEUE); let (tx, rx) = mpsc::channel(TERMINAL_RELAY_QUEUE);
session.operator_tx = Some(tx); session.operator_tx = Some(tx);
Ok((rx, replay)) Ok(rx)
} }
pub async fn relay_to_agent( pub async fn relay_to_agent(
@@ -305,7 +296,7 @@ impl TerminalRegistry {
let session = active_session(&mut sessions, terminal_id)?; let session = active_session(&mut sessions, terminal_id)?;
session.pending_agent_bytes += relay_frame_size(&frame); session.pending_agent_bytes += relay_frame_size(&frame);
session.pending_agent.push_back(frame); session.pending_agent.push_back(frame);
while session.pending_agent_bytes > TERMINAL_REPLAY_BYTES { while session.pending_agent_bytes > TERMINAL_PENDING_AGENT_BYTES {
if let Some(dropped) = session.pending_agent.pop_front() { if let Some(dropped) = session.pending_agent.pop_front() {
session.pending_agent_bytes -= relay_frame_size(&dropped); session.pending_agent_bytes -= relay_frame_size(&dropped);
} else { } else {
@@ -323,7 +314,6 @@ impl TerminalRegistry {
let operator_tx = { let operator_tx = {
let mut sessions = self.inner.lock().await; let mut sessions = self.inner.lock().await;
let session = active_session(&mut sessions, terminal_id)?; let session = active_session(&mut sessions, terminal_id)?;
push_replay(session, frame.clone());
session.operator_tx.clone() session.operator_tx.clone()
}; };
@@ -332,7 +322,8 @@ impl TerminalRegistry {
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(_) => { Err(_) => {
// The browser task may not have marked itself detached yet. // The browser task may not have marked itself detached yet.
// Preserve this frame so that race does not kill the PTY. // Detach its stale sender; the agent's parsed screen remains
// authoritative and will reconstruct the next attachment.
let mut sessions = self.inner.lock().await; let mut sessions = self.inner.lock().await;
let session = active_session(&mut sessions, terminal_id)?; let session = active_session(&mut sessions, terminal_id)?;
session.operator_tx = None; session.operator_tx = None;
@@ -433,18 +424,6 @@ fn relay_frame_size(frame: &TerminalRelayFrame) -> usize {
} }
} }
fn push_replay(session: &mut TerminalSession, frame: TerminalRelayFrame) {
session.replay_bytes += relay_frame_size(&frame);
session.replay.push_back(frame);
while session.replay_bytes > TERMINAL_REPLAY_BYTES {
if let Some(dropped) = session.replay.pop_front() {
session.replay_bytes -= relay_frame_size(&dropped);
} else {
break;
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -485,31 +464,29 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn detached_output_replay_is_bounded() { async fn detached_output_is_not_transcribed_by_control_plane() {
let registry = TerminalRegistry::new(); let registry = TerminalRegistry::new();
let created = registry.create("router".into()).await.expect("create"); let created = registry.create("router".into()).await.expect("create");
for _ in 0..10 { registry
registry .relay_from_agent(
.relay_from_agent( &created.terminal_id,
&created.terminal_id, TerminalRelayFrame::Binary(b"not retained".to_vec()),
TerminalRelayFrame::Binary(vec![0; TERMINAL_REPLAY_BYTES / 4]), )
) .await
.await .expect("ignore detached output");
.expect("buffer output");
}
let (_, replay) = registry let mut outbound = registry
.attach_operator(&created.terminal_id, &created.attachment_token) .attach_operator(&created.terminal_id, &created.attachment_token)
.await .await
.expect("attach operator"); .expect("attach operator");
assert!(replay.iter().map(relay_frame_size).sum::<usize>() <= TERMINAL_REPLAY_BYTES); assert!(outbound.try_recv().is_err());
} }
#[tokio::test] #[tokio::test]
async fn attached_output_remains_available_for_remount() { async fn attached_output_is_delivered_live_only() {
let registry = TerminalRegistry::new(); let registry = TerminalRegistry::new();
let created = registry.create("router".into()).await.expect("create"); let created = registry.create("router".into()).await.expect("create");
let (mut outbound, _) = registry let mut outbound = registry
.attach_operator(&created.terminal_id, &created.attachment_token) .attach_operator(&created.terminal_id, &created.attachment_token)
.await .await
.expect("attach operator"); .expect("attach operator");
@@ -525,15 +502,12 @@ mod tests {
.issue_attachment_token(&created.terminal_id) .issue_attachment_token(&created.terminal_id)
.await .await
.expect("reattach token"); .expect("reattach token");
let (_, replay) = registry let mut remounted = registry
.attach_operator(&created.terminal_id, &token) .attach_operator(&created.terminal_id, &token)
.await .await
.expect("reattach operator"); .expect("reattach operator");
assert!(matches!( assert!(remounted.try_recv().is_err());
replay.as_slice(),
[TerminalRelayFrame::Binary(bytes)] if bytes == b"recent prompt"
));
} }
#[tokio::test] #[tokio::test]