just fork the thing and fix the thing
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/vt100"]
|
||||||
|
path = vendor/vt100
|
||||||
|
url = https://github.com/ldlda/vt100-rust-1
|
||||||
Generated
-3
@@ -3615,8 +3615,6 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "vt100"
|
name = "vt100"
|
||||||
version = "0.16.2"
|
version = "0.16.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
@@ -3673,7 +3671,6 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"unicode-width",
|
|
||||||
"url",
|
"url",
|
||||||
"vt100",
|
"vt100",
|
||||||
"wakey",
|
"wakey",
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ strip = true
|
|||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||||
|
exclude = ["vendor/vt100"]
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# vt100 exposes only its active grid. Wakey's reconnect snapshot needs the
|
||||||
|
# retained primary grid while a full-screen application owns the alternate
|
||||||
|
# grid, so keep the small read-only extension local and auditable.
|
||||||
|
vt100 = { path = "vendor/vt100" }
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ The operator UI lists live sessions as tabs and stores only the active terminal
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
+1
Submodule vendor/vt100 added at 5beb476605
@@ -39,7 +39,6 @@ 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"
|
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" }
|
||||||
|
|||||||
+53
-58
@@ -11,7 +11,6 @@ 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_SCROLLBACK_ROWS: usize = 5_000;
|
const TERMINAL_SCROLLBACK_ROWS: usize = 5_000;
|
||||||
@@ -40,63 +39,10 @@ impl TerminalState {
|
|||||||
self.parser.screen_mut().set_size(rows, cols);
|
self.parser.screen_mut().set_size(rows, cols);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot(&mut self) -> Vec<u8> {
|
fn snapshot(&self) -> Vec<u8> {
|
||||||
let screen = self.parser.screen_mut();
|
self.parser
|
||||||
let (rows, cols) = screen.size();
|
.screen()
|
||||||
let alternate_screen = screen.alternate_screen();
|
.snapshot_formatted(TERMINAL_SCROLLBACK_ROWS)
|
||||||
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_cjk(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_cjk(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,6 +683,55 @@ mod tests {
|
|||||||
assert_eq!(restored.screen().contents(), "full-screen");
|
assert_eq!(restored.screen().contents(), "full-screen");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alternate_screen_snapshot_restores_hidden_primary_on_exit() {
|
||||||
|
let mut state = TerminalState::new(3, 20);
|
||||||
|
state.process(b"\x1b[31mshell history\r\n$ btop\x1b[?1049h\x1b[34mbtop dashboard");
|
||||||
|
|
||||||
|
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(), "btop dashboard");
|
||||||
|
assert_eq!(restored.screen().fgcolor(), vt100::Color::Idx(4));
|
||||||
|
|
||||||
|
// Compare the reattached terminal with the original parser after both
|
||||||
|
// receive the application's real alternate-screen exit sequence.
|
||||||
|
state.process(b"\x1b[?1049l");
|
||||||
|
restored.process(b"\x1b[?1049l");
|
||||||
|
assert_eq!(
|
||||||
|
restored.screen().contents(),
|
||||||
|
state.parser.screen().contents()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restored.screen().cursor_position(),
|
||||||
|
state.parser.screen().cursor_position()
|
||||||
|
);
|
||||||
|
assert_eq!(restored.screen().fgcolor(), vt100::Color::Idx(1));
|
||||||
|
|
||||||
|
restored.screen_mut().set_scrollback(usize::MAX);
|
||||||
|
assert!(restored.screen().contents().contains("shell history"));
|
||||||
|
assert!(!restored.screen().contents().contains("btop dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_does_not_change_the_source_viewport_or_state() {
|
||||||
|
let mut state = TerminalState::new(3, 20);
|
||||||
|
for line in 0..10 {
|
||||||
|
state.process(format!("history {line}\r\n").as_bytes());
|
||||||
|
}
|
||||||
|
state.parser.screen_mut().set_scrollback(4);
|
||||||
|
|
||||||
|
let before_contents = state.parser.screen().contents();
|
||||||
|
let before_state = state.parser.screen().state_formatted();
|
||||||
|
let before_scrollback = state.parser.screen().scrollback();
|
||||||
|
let _ = state.snapshot();
|
||||||
|
|
||||||
|
assert_eq!(state.parser.screen().contents(), before_contents);
|
||||||
|
assert_eq!(state.parser.screen().state_formatted(), before_state);
|
||||||
|
assert_eq!(state.parser.screen().scrollback(), before_scrollback);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn snapshot_is_split_at_the_terminal_frame_limit() {
|
async fn snapshot_is_split_at_the_terminal_frame_limit() {
|
||||||
let snapshot = vec![7; MAX_TERMINAL_FRAME_BYTES * 2 + 1];
|
let snapshot = vec![7; MAX_TERMINAL_FRAME_BYTES * 2 + 1];
|
||||||
|
|||||||
Reference in New Issue
Block a user