Compare commits
2
Commits
e5f3830e00
...
50f21a5be2
No files matched your search
Generated
+5
-5
@@ -3624,7 +3624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3645,7 +3645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3670,7 +3670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3699,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"macaddr",
|
||||
"serde",
|
||||
@@ -3711,7 +3711,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ When the operator socket disconnects, the session becomes detached. Navigation,
|
||||
|
||||
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 `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.
|
||||
After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 1,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 connected browser independently retains up to 10,000 rows because that memory belongs to the operator device rather than the agent host.
|
||||
|
||||
Wakey carries a small, read-only extension to `vt100` because its public API exposes only the active grid. A snapshot streams bounded history from the primary grid, redraws that grid with its saved cursor and attributes, and then reconstructs the alternate grid when a full-screen application is active. The parser is never resized or switched while taking a snapshot. Consequently, reconnecting during `btop` restores the application while preserving the hidden shell history and state that reappear when it exits.
|
||||
|
||||
@@ -90,7 +90,9 @@ The agent reports a normal exit status or terminating signal when available. The
|
||||
|
||||
Terminal transport must not use unbounded queues.
|
||||
|
||||
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.
|
||||
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 1,000-row reconnect scrollback limit. The control plane retains only a bounded queue of operator controls while an agent relay is reconnecting.
|
||||
|
||||
While a relay is attached, saturation applies lossless backpressure instead of dropping terminal bytes or disconnecting the relay. The agent retains the frame that discovered saturation and pauses further PTY reads until bounded relay capacity returns. Input, resize, close, lifecycle, and reconnect handling remain active while output is backpressured. Reconnect snapshots use the same ordered output path and duplicate snapshot requests are coalesced while one is pending. This is required for terminal protocols such as sixel, where dropping one output frame corrupts the rest of the stream.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ export function TerminalPage({
|
||||
scrollbar: {
|
||||
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||
},
|
||||
scrollback: 5000,
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: "#0b1117",
|
||||
foreground: "#e5e7eb",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
+193
-60
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::Duration;
|
||||
@@ -13,8 +13,9 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const MAX_TERMINAL_FRAME_BYTES: usize = 64 * 1024;
|
||||
const TERMINAL_SCROLLBACK_ROWS: usize = 5_000;
|
||||
const TERMINAL_SCROLLBACK_ROWS: usize = 1_000;
|
||||
const RELAY_INPUT_QUEUE: usize = 32;
|
||||
const RELAY_OUTPUT_QUEUE: usize = 32;
|
||||
const PTY_EXIT_DRAIN_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const PROCESS_SIGNAL_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -26,6 +27,90 @@ struct TerminalState {
|
||||
parser: vt100::Parser,
|
||||
}
|
||||
|
||||
/// Buffers ordered terminal output without letting a slow relay grow memory
|
||||
/// without bound. Once a frame is pending, the PTY read branch is paused until
|
||||
/// the relay channel has capacity; control input remains independently active.
|
||||
#[derive(Default)]
|
||||
struct RelayOutputBuffer {
|
||||
sender: Option<mpsc::Sender<Message>>,
|
||||
pending: VecDeque<Message>,
|
||||
snapshot_pending: bool,
|
||||
}
|
||||
|
||||
impl RelayOutputBuffer {
|
||||
fn connect(&mut self, sender: mpsc::Sender<Message>) {
|
||||
self.disconnect();
|
||||
self.sender = Some(sender);
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) {
|
||||
self.sender = None;
|
||||
self.pending.clear();
|
||||
self.snapshot_pending = false;
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.sender.is_some()
|
||||
}
|
||||
|
||||
fn has_pending(&self) -> bool {
|
||||
!self.pending.is_empty()
|
||||
}
|
||||
|
||||
fn pending_sender(&self) -> Option<mpsc::Sender<Message>> {
|
||||
self.has_pending().then(|| self.sender.clone()).flatten()
|
||||
}
|
||||
|
||||
fn queue_live(&mut self, frame: Message) {
|
||||
debug_assert!(
|
||||
self.pending.is_empty(),
|
||||
"PTY reads must pause while relay output is pending"
|
||||
);
|
||||
self.queue_frame(frame);
|
||||
}
|
||||
|
||||
fn queue_frame(&mut self, frame: Message) {
|
||||
let Some(sender) = self.sender.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !self.pending.is_empty() {
|
||||
self.pending.push_back(frame);
|
||||
return;
|
||||
}
|
||||
match sender.try_send(frame) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::error::TrySendError::Full(frame)) => {
|
||||
self.pending.push_back(frame);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => self.disconnect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_snapshot(&mut self, snapshot: Vec<u8>) {
|
||||
if self.sender.is_none() || self.snapshot_pending {
|
||||
return;
|
||||
}
|
||||
for chunk in snapshot.chunks(MAX_TERMINAL_FRAME_BYTES) {
|
||||
self.queue_frame(Message::Binary(chunk.to_vec().into()));
|
||||
if self.sender.is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.snapshot_pending = self.has_pending();
|
||||
}
|
||||
|
||||
fn send_reserved(&mut self, permit: mpsc::OwnedPermit<Message>) {
|
||||
let frame = self
|
||||
.pending
|
||||
.pop_front()
|
||||
.expect("relay permit requires pending output");
|
||||
permit.send(frame);
|
||||
if self.pending.is_empty() {
|
||||
self.snapshot_pending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalState {
|
||||
fn new(rows: u16, cols: u16) -> Self {
|
||||
Self {
|
||||
@@ -250,7 +335,7 @@ async fn run_terminal(
|
||||
);
|
||||
|
||||
let (relay_input_tx, mut relay_input_rx) = mpsc::channel(RELAY_INPUT_QUEUE);
|
||||
let mut relay_output: Option<mpsc::Sender<Message>> = None;
|
||||
let mut relay_output = RelayOutputBuffer::default();
|
||||
let mut relay_task: Option<tokio::task::JoinHandle<()>> = None;
|
||||
let mut relay_generation = 0_u64;
|
||||
let mut terminal_state = TerminalState::new(rows, cols);
|
||||
@@ -261,6 +346,7 @@ async fn run_terminal(
|
||||
let mut drain_deadline: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
|
||||
let mut ttl_deadline = session_ttl.map(|ttl| Box::pin(tokio::time::sleep(ttl)));
|
||||
loop {
|
||||
let pending_output_sender = relay_output.pending_sender();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = &mut cancel => {
|
||||
@@ -303,12 +389,7 @@ async fn run_terminal(
|
||||
terminal_state.resize(rows, cols);
|
||||
}
|
||||
RelayInput::Snapshot { generation } if generation == relay_generation => {
|
||||
if !send_terminal_snapshot(terminal_state.snapshot(), &mut relay_output) {
|
||||
if let Some(task) = relay_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
warn!(terminal_id = %terminal_id, "terminal relay output queue saturated during snapshot");
|
||||
}
|
||||
relay_output.queue_snapshot(terminal_state.snapshot());
|
||||
if let Err(err) = writer.refresh() {
|
||||
warn!(terminal_id = %terminal_id, error = %err, "terminal redraw signal failed");
|
||||
}
|
||||
@@ -318,10 +399,10 @@ async fn run_terminal(
|
||||
break;
|
||||
}
|
||||
RelayInput::Connected { generation, output } if generation == relay_generation => {
|
||||
relay_output = Some(output);
|
||||
relay_output.connect(output);
|
||||
}
|
||||
RelayInput::Disconnected { generation } if generation == relay_generation => {
|
||||
relay_output = None;
|
||||
relay_output.disconnect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -333,8 +414,8 @@ async fn run_terminal(
|
||||
}
|
||||
relay_generation = relay_generation.wrapping_add(1);
|
||||
let generation = relay_generation;
|
||||
let (output_tx, output_rx) = mpsc::channel(32);
|
||||
relay_output = None;
|
||||
let (output_tx, output_rx) = mpsc::channel(RELAY_OUTPUT_QUEUE);
|
||||
relay_output.disconnect();
|
||||
let config = config.clone();
|
||||
let terminal_id = terminal_id.clone();
|
||||
let relay_input_tx = relay_input_tx.clone();
|
||||
@@ -353,20 +434,23 @@ async fn run_terminal(
|
||||
let _ = relay_input_tx.send(RelayInput::Disconnected { generation }).await;
|
||||
}));
|
||||
}
|
||||
read = reader.read(&mut output) => {
|
||||
permit = reserve_relay_output(pending_output_sender), if relay_output.has_pending() => {
|
||||
match permit {
|
||||
Ok(permit) => relay_output.send_reserved(permit),
|
||||
Err(()) => {
|
||||
relay_output.disconnect();
|
||||
if let Some(task) = relay_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
read = reader.read(&mut output), if !relay_output.has_pending() => {
|
||||
match read {
|
||||
Ok(0) => break,
|
||||
Ok(count) => {
|
||||
terminal_state.process(&output[..count]);
|
||||
if !send_terminal_output(
|
||||
Message::Binary(output[..count].to_vec().into()),
|
||||
&mut relay_output,
|
||||
) {
|
||||
if let Some(task) = relay_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
warn!(terminal_id = %terminal_id, "terminal relay output queue saturated");
|
||||
}
|
||||
relay_output.queue_live(Message::Binary(output[..count].to_vec().into()));
|
||||
}
|
||||
// Linux PTY masters commonly report EIO after the slave closes.
|
||||
Err(err) if err.raw_os_error() == Some(5) => break,
|
||||
@@ -380,12 +464,12 @@ async fn run_terminal(
|
||||
Some(status) => status,
|
||||
None => terminate_process_group(&mut child, process_group).await?,
|
||||
};
|
||||
if let Some(output) = relay_output {
|
||||
if relay_output.is_connected() {
|
||||
let control = TerminalControl::Exited {
|
||||
exit_code: status.code(),
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string(&control) {
|
||||
let _ = output.try_send(Message::Text(text.into()));
|
||||
relay_output.queue_frame(Message::Text(text.into()));
|
||||
}
|
||||
}
|
||||
info!(terminal_id = %terminal_id, exit_code = ?status.code(), requested_close, ttl_expired, "terminal worker exited");
|
||||
@@ -507,27 +591,13 @@ async fn run_terminal_relay(relay: RelayConnection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_terminal_output(frame: Message, relay: &mut Option<mpsc::Sender<Message>>) -> bool {
|
||||
if let Some(tx) = relay.as_ref()
|
||||
&& tx.try_send(frame).is_err()
|
||||
{
|
||||
*relay = None;
|
||||
return false;
|
||||
async fn reserve_relay_output(
|
||||
sender: Option<mpsc::Sender<Message>>,
|
||||
) -> Result<mpsc::OwnedPermit<Message>, ()> {
|
||||
match sender {
|
||||
Some(sender) => sender.reserve_owned().await.map_err(|_| ()),
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn send_terminal_snapshot(snapshot: Vec<u8>, relay: &mut Option<mpsc::Sender<Message>>) -> bool {
|
||||
let Some(tx) = relay.as_ref() else {
|
||||
return true;
|
||||
};
|
||||
for chunk in snapshot.chunks(MAX_TERMINAL_FRAME_BYTES) {
|
||||
if tx.try_send(Message::Binary(chunk.to_vec().into())).is_err() {
|
||||
*relay = None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
@@ -893,13 +963,19 @@ mod tests {
|
||||
#[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);
|
||||
|
||||
assert!(send_terminal_snapshot(snapshot.clone(), &mut relay));
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let mut relay = RelayOutputBuffer::default();
|
||||
relay.connect(tx);
|
||||
relay.queue_snapshot(snapshot.clone());
|
||||
|
||||
let mut restored = Vec::new();
|
||||
for _ in 0..3 {
|
||||
if rx.is_empty() {
|
||||
let permit = reserve_relay_output(relay.pending_sender())
|
||||
.await
|
||||
.expect("relay remains connected");
|
||||
relay.send_reserved(permit);
|
||||
}
|
||||
let Message::Binary(chunk) = rx.recv().await.expect("snapshot chunk") else {
|
||||
panic!("snapshot chunks must be binary");
|
||||
};
|
||||
@@ -907,23 +983,80 @@ mod tests {
|
||||
restored.extend_from_slice(&chunk);
|
||||
}
|
||||
assert_eq!(restored, snapshot);
|
||||
assert!(!relay.has_pending());
|
||||
assert!(relay.is_connected());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saturated_relay_output_stays_attached_without_dropping() {
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let mut relay = RelayOutputBuffer::default();
|
||||
relay.connect(tx);
|
||||
relay.queue_live(Message::Binary(vec![1].into()));
|
||||
// This read discovers saturation. The worker retains it, then pauses
|
||||
// further PTY reads until relay capacity becomes available.
|
||||
relay.queue_live(Message::Binary(vec![2].into()));
|
||||
|
||||
assert!(relay.is_connected());
|
||||
assert!(relay.has_pending());
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("original queued frame").into_data(),
|
||||
vec![1]
|
||||
);
|
||||
let permit = reserve_relay_output(relay.pending_sender())
|
||||
.await
|
||||
.expect("relay remains connected");
|
||||
relay.send_reserved(permit);
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("backpressured frame").into_data(),
|
||||
vec![2]
|
||||
);
|
||||
assert!(!relay.has_pending());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_waits_behind_pending_live_output() {
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let mut relay = RelayOutputBuffer::default();
|
||||
relay.connect(tx);
|
||||
relay.queue_live(Message::Binary(vec![1].into()));
|
||||
relay.queue_live(Message::Binary(vec![2].into()));
|
||||
relay.queue_snapshot(vec![3]);
|
||||
|
||||
let mut received = Vec::new();
|
||||
for _ in 0..3 {
|
||||
if rx.is_empty() {
|
||||
let permit = reserve_relay_output(relay.pending_sender())
|
||||
.await
|
||||
.expect("relay remains connected");
|
||||
relay.send_reserved(permit);
|
||||
}
|
||||
received.extend(rx.recv().await.expect("ordered relay frame").into_data());
|
||||
}
|
||||
assert_eq!(received, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturated_relay_output_detaches_without_waiting() {
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
tx.try_send(Message::Binary(vec![1].into()))
|
||||
.expect("prefill relay queue");
|
||||
let mut relay = Some(tx);
|
||||
fn replacement_relay_does_not_receive_old_pending_output() {
|
||||
let (old_tx, mut old_rx) = mpsc::channel(1);
|
||||
let (new_tx, mut new_rx) = mpsc::channel(1);
|
||||
let mut relay = RelayOutputBuffer::default();
|
||||
relay.connect(old_tx);
|
||||
relay.queue_live(Message::Binary(vec![1].into()));
|
||||
relay.queue_live(Message::Binary(vec![2].into()));
|
||||
|
||||
relay.connect(new_tx);
|
||||
relay.queue_live(Message::Binary(vec![3].into()));
|
||||
|
||||
assert!(!send_terminal_output(
|
||||
Message::Binary(vec![2].into()),
|
||||
&mut relay,
|
||||
));
|
||||
assert!(relay.is_none());
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("original queued frame").into_data(),
|
||||
old_rx.try_recv().expect("old queued frame").into_data(),
|
||||
vec![1]
|
||||
);
|
||||
assert!(old_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
new_rx.try_recv().expect("new relay frame").into_data(),
|
||||
vec![3]
|
||||
);
|
||||
assert!(!relay.has_pending());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-core"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
Reference in new issue
Block a user