just fork the thing and fix the thing

This commit is contained in:
lda
2026-07-16 07:06:59 +07:00 Verified
parent 77824523f9
commit 536b50220f
7 changed files with 66 additions and 62 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/vt100"]
path = vendor/vt100
url = https://github.com/ldlda/vt100-rust-1
Generated
-3
View File
@@ -3615,8 +3615,6 @@ 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",
@@ -3673,7 +3671,6 @@ dependencies = [
"toml",
"tracing",
"tracing-subscriber",
"unicode-width",
"url",
"vt100",
"wakey",
+7
View File
@@ -31,6 +31,13 @@ strip = true
[workspace]
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]
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.
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.
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.
Vendored Submodule
+1
Submodule vendor/vt100 added at 5beb476605
-1
View File
@@ -39,7 +39,6 @@ tracing-subscriber = { version = "0.3", features = [
] }
time = { version = "0.3", features = ["formatting", "local-offset"] }
url = "2"
unicode-width = "0.2"
vt100 = "0.16.2"
wakey = { path = "..", registry = "gitea", version = "0" }
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
+53 -58
View File
@@ -11,7 +11,6 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite::Message;
use tracing::{info, warn};
use unicode_width::UnicodeWidthStr;
const MAX_TERMINAL_FRAME_BYTES: usize = 64 * 1024;
const TERMINAL_SCROLLBACK_ROWS: usize = 5_000;
@@ -40,63 +39,10 @@ impl TerminalState {
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_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
fn snapshot(&self) -> Vec<u8> {
self.parser
.screen()
.snapshot_formatted(TERMINAL_SCROLLBACK_ROWS)
}
}
@@ -737,6 +683,55 @@ mod tests {
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]
async fn snapshot_is_split_at_the_terminal_frame_limit() {
let snapshot = vec![7; MAX_TERMINAL_FRAME_BYTES * 2 + 1];