Make restart reconnect persistent
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-21 10:51:12 -04:00
parent a029564d80
commit fcc21c7aef
6 changed files with 118 additions and 55 deletions
+25 -29
View File
@@ -691,7 +691,7 @@ async fn run_selftest_command(config: &dosh::config::ClientConfig, args: &Args)
let overlay = render_status_overlay("[dosh] reconnecting — last contact 3s ago", 24);
ensure_tui_safe_status_overlay(&overlay)?;
println!("[ok] disconnect UI: save/restore bottom-row overlay");
println!("[ok] disconnect UI: save/restore top status bar");
parse_local_forward("18080:127.0.0.1:80")?;
parse_remote_forward("19090:127.0.0.1:5432")?;
@@ -1659,9 +1659,9 @@ fn select_session(requested: Option<&str>, _force_new: bool) -> String {
if let Some(session) = requested {
return session.to_string();
}
// Implicit, ephemeral session: the server recognizes this name shape
// (`protocol::is_implicit_session_name`) and does NOT persist it across
// restarts, since you can never reattach to a random name.
// Implicit session: this gives plain `dosh host` a fresh terminal by
// default. The server can still persist it across restarts because the
// reconnect ticket cache keeps the generated name.
protocol::generate_implicit_session_name()
}
@@ -4735,16 +4735,13 @@ fn render_frame(frame: &Frame) -> Result<()> {
/// idle period (the run loop only pings after 2s of quiet) never flashes it.
const DISCONNECT_STATUS_THRESHOLD_SECS: u64 = 2;
/// Non-destructive, mosh-style disconnect status line.
/// Non-destructive, mosh-style disconnect status bar.
///
/// When server packets stop arriving we paint a single, unobtrusive line on the
/// terminal's *bottom row* — e.g. reverse-video cyan `[dosh] last contact 3s ago
/// — reconnecting…` — using save/restore cursor (ESC 7 / ESC 8) so the real
/// application cursor never moves and full-screen TUIs are not corrupted. (The
/// old in-band overlay wrote over the active line and corrupted TUIs; this draws
/// only the last row, parks the cursor there, then restores, touching nothing
/// the app owns.) The line is refreshed on the status timer tick and cleared the
/// instant a packet resumes.
/// When server packets stop arriving we paint one blue line on the terminal's
/// top row — e.g. `[dosh] reconnecting — last contact 3s ago` — using
/// save/restore cursor (ESC 7 / ESC 8) so the real application cursor never
/// moves and full-screen TUIs are not corrupted. The bar is refreshed on the
/// status timer tick and cleared the instant a packet resumes.
///
/// The decision logic and the rendered text are pure and unit-tested; only
/// [`DisconnectStatus::emit`] / [`DisconnectStatus::emit_clear`] touch stdout.
@@ -4763,7 +4760,7 @@ struct DisconnectStatus {
enum StatusAction {
/// Leave the screen as-is (already correct).
None,
/// Paint/repaint the status line with this exact text on the bottom row.
/// Paint/repaint the status bar with this exact text on the top row.
Show(String),
/// Erase the status line (link recovered or feature disabled).
Clear,
@@ -4868,26 +4865,25 @@ impl DisconnectStatus {
}
}
/// Pure: build the escape sequence that paints `text` on `rows`-tall terminal's
/// bottom row with reverse-video cyan, leaving the cursor where it was.
fn render_status_overlay(text: &str, rows: u16) -> Vec<u8> {
/// Pure: build the escape sequence that paints `text` on a top blue status bar,
/// leaving the cursor where it was.
fn render_status_overlay(text: &str, _rows: u16) -> Vec<u8> {
let mut out = Vec::with_capacity(text.len() + 24);
out.extend_from_slice(b"\x1b7"); // save cursor
// Move to bottom row, column 1.
out.extend_from_slice(format!("\x1b[{};1H", rows.max(1)).as_bytes());
out.extend_from_slice(b"\x1b[1;1H"); // move to top row, column 1
out.extend_from_slice(b"\x1b[2K"); // clear entire row
out.extend_from_slice(b"\x1b[7;36m"); // reverse video + cyan
out.extend_from_slice(b"\x1b[44;37m"); // blue background + white text
out.extend_from_slice(text.as_bytes());
out.extend_from_slice(b"\x1b[0m"); // reset attributes
out.extend_from_slice(b"\x1b8"); // restore cursor
out
}
/// Pure: build the escape sequence that clears the bottom-row status line.
fn render_status_clear(rows: u16) -> Vec<u8> {
/// Pure: build the escape sequence that clears the top status bar.
fn render_status_clear(_rows: u16) -> Vec<u8> {
let mut out = Vec::with_capacity(12);
out.extend_from_slice(b"\x1b7");
out.extend_from_slice(format!("\x1b[{};1H", rows.max(1)).as_bytes());
out.extend_from_slice(b"\x1b[1;1H");
out.extend_from_slice(b"\x1b[2K");
out.extend_from_slice(b"\x1b8");
out
@@ -5993,19 +5989,19 @@ mod tests {
}
/// The rendered overlay must be non-destructive: save cursor (ESC 7), park on
/// the bottom row, paint reverse-video cyan, then restore cursor (ESC 8) so
/// the top row, paint the blue reconnect bar, then restore cursor (ESC 8) so
/// the application's cursor and full-screen TUIs are never disturbed.
#[test]
fn disconnect_status_overlay_is_save_restore_bottom_row() {
fn disconnect_status_overlay_is_save_restore_top_blue_bar() {
let bytes = render_status_overlay("[dosh] reconnecting — last contact 3s ago", 24);
ensure_tui_safe_status_overlay(&bytes).unwrap();
assert!(bytes.starts_with(b"\x1b7"), "must save cursor first");
assert!(bytes.ends_with(b"\x1b8"), "must restore cursor last");
// Positions to the last row (row 24), column 1.
assert!(bytes.windows(7).any(|w| w == b"\x1b[24;1H"));
// Clears the row and uses reverse-video (7) + cyan (36).
// Positions to the first row, column 1.
assert!(bytes.windows(6).any(|w| w == b"\x1b[1;1H"));
// Clears the row and uses a blue background with white text.
assert!(bytes.windows(4).any(|w| w == b"\x1b[2K"));
assert!(bytes.windows(7).any(|w| w == b"\x1b[7;36m"));
assert!(bytes.windows(8).any(|w| w == b"\x1b[44;37m"));
// Resets attributes before restoring the cursor.
assert!(bytes.windows(4).any(|w| w == b"\x1b[0m"));