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
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:
@@ -432,6 +432,7 @@ native_auth_rate_limit_per_minute = 30
|
|||||||
allow_tcp_forwarding = true
|
allow_tcp_forwarding = true
|
||||||
allow_remote_forwarding = false
|
allow_remote_forwarding = false
|
||||||
allow_agent_forwarding = false
|
allow_agent_forwarding = false
|
||||||
|
persist_sessions = true
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Type=simple
|
|||||||
ExecStart=%h/.local/bin/dosh-server serve
|
ExecStart=%h/.local/bin/dosh-server serve
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=1
|
RestartSec=1
|
||||||
|
KillMode=process
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
|
|||||||
+25
-29
@@ -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);
|
let overlay = render_status_overlay("[dosh] reconnecting — last contact 3s ago", 24);
|
||||||
ensure_tui_safe_status_overlay(&overlay)?;
|
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_local_forward("18080:127.0.0.1:80")?;
|
||||||
parse_remote_forward("19090:127.0.0.1:5432")?;
|
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 {
|
if let Some(session) = requested {
|
||||||
return session.to_string();
|
return session.to_string();
|
||||||
}
|
}
|
||||||
// Implicit, ephemeral session: the server recognizes this name shape
|
// Implicit session: this gives plain `dosh host` a fresh terminal by
|
||||||
// (`protocol::is_implicit_session_name`) and does NOT persist it across
|
// default. The server can still persist it across restarts because the
|
||||||
// restarts, since you can never reattach to a random name.
|
// reconnect ticket cache keeps the generated name.
|
||||||
protocol::generate_implicit_session_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.
|
/// idle period (the run loop only pings after 2s of quiet) never flashes it.
|
||||||
const DISCONNECT_STATUS_THRESHOLD_SECS: u64 = 2;
|
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
|
/// When server packets stop arriving we paint one blue line on the terminal's
|
||||||
/// terminal's *bottom row* — e.g. reverse-video cyan `[dosh] last contact 3s ago
|
/// top row — e.g. `[dosh] reconnecting — last contact 3s ago` — using
|
||||||
/// — reconnecting…` — using save/restore cursor (ESC 7 / ESC 8) so the real
|
/// save/restore cursor (ESC 7 / ESC 8) so the real application cursor never
|
||||||
/// application cursor never moves and full-screen TUIs are not corrupted. (The
|
/// moves and full-screen TUIs are not corrupted. The bar is refreshed on the
|
||||||
/// old in-band overlay wrote over the active line and corrupted TUIs; this draws
|
/// status timer tick and cleared the instant a packet resumes.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// The decision logic and the rendered text are pure and unit-tested; only
|
/// The decision logic and the rendered text are pure and unit-tested; only
|
||||||
/// [`DisconnectStatus::emit`] / [`DisconnectStatus::emit_clear`] touch stdout.
|
/// [`DisconnectStatus::emit`] / [`DisconnectStatus::emit_clear`] touch stdout.
|
||||||
@@ -4763,7 +4760,7 @@ struct DisconnectStatus {
|
|||||||
enum StatusAction {
|
enum StatusAction {
|
||||||
/// Leave the screen as-is (already correct).
|
/// Leave the screen as-is (already correct).
|
||||||
None,
|
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),
|
Show(String),
|
||||||
/// Erase the status line (link recovered or feature disabled).
|
/// Erase the status line (link recovered or feature disabled).
|
||||||
Clear,
|
Clear,
|
||||||
@@ -4868,26 +4865,25 @@ impl DisconnectStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure: build the escape sequence that paints `text` on `rows`-tall terminal's
|
/// Pure: build the escape sequence that paints `text` on a top blue status bar,
|
||||||
/// bottom row with reverse-video cyan, leaving the cursor where it was.
|
/// leaving the cursor where it was.
|
||||||
fn render_status_overlay(text: &str, rows: u16) -> Vec<u8> {
|
fn render_status_overlay(text: &str, _rows: u16) -> Vec<u8> {
|
||||||
let mut out = Vec::with_capacity(text.len() + 24);
|
let mut out = Vec::with_capacity(text.len() + 24);
|
||||||
out.extend_from_slice(b"\x1b7"); // save cursor
|
out.extend_from_slice(b"\x1b7"); // save cursor
|
||||||
// Move to bottom row, column 1.
|
out.extend_from_slice(b"\x1b[1;1H"); // move to top row, column 1
|
||||||
out.extend_from_slice(format!("\x1b[{};1H", rows.max(1)).as_bytes());
|
|
||||||
out.extend_from_slice(b"\x1b[2K"); // clear entire row
|
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(text.as_bytes());
|
||||||
out.extend_from_slice(b"\x1b[0m"); // reset attributes
|
out.extend_from_slice(b"\x1b[0m"); // reset attributes
|
||||||
out.extend_from_slice(b"\x1b8"); // restore cursor
|
out.extend_from_slice(b"\x1b8"); // restore cursor
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure: build the escape sequence that clears the bottom-row status line.
|
/// Pure: build the escape sequence that clears the top status bar.
|
||||||
fn render_status_clear(rows: u16) -> Vec<u8> {
|
fn render_status_clear(_rows: u16) -> Vec<u8> {
|
||||||
let mut out = Vec::with_capacity(12);
|
let mut out = Vec::with_capacity(12);
|
||||||
out.extend_from_slice(b"\x1b7");
|
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"\x1b[2K");
|
||||||
out.extend_from_slice(b"\x1b8");
|
out.extend_from_slice(b"\x1b8");
|
||||||
out
|
out
|
||||||
@@ -5993,19 +5989,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The rendered overlay must be non-destructive: save cursor (ESC 7), park on
|
/// 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.
|
/// the application's cursor and full-screen TUIs are never disturbed.
|
||||||
#[test]
|
#[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);
|
let bytes = render_status_overlay("[dosh] reconnecting — last contact 3s ago", 24);
|
||||||
ensure_tui_safe_status_overlay(&bytes).unwrap();
|
ensure_tui_safe_status_overlay(&bytes).unwrap();
|
||||||
assert!(bytes.starts_with(b"\x1b7"), "must save cursor first");
|
assert!(bytes.starts_with(b"\x1b7"), "must save cursor first");
|
||||||
assert!(bytes.ends_with(b"\x1b8"), "must restore cursor last");
|
assert!(bytes.ends_with(b"\x1b8"), "must restore cursor last");
|
||||||
// Positions to the last row (row 24), column 1.
|
// Positions to the first row, column 1.
|
||||||
assert!(bytes.windows(7).any(|w| w == b"\x1b[24;1H"));
|
assert!(bytes.windows(6).any(|w| w == b"\x1b[1;1H"));
|
||||||
// Clears the row and uses reverse-video (7) + cyan (36).
|
// Clears the row and uses a blue background with white text.
|
||||||
assert!(bytes.windows(4).any(|w| w == b"\x1b[2K"));
|
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.
|
// Resets attributes before restoring the cursor.
|
||||||
assert!(bytes.windows(4).any(|w| w == b"\x1b[0m"));
|
assert!(bytes.windows(4).any(|w| w == b"\x1b[0m"));
|
||||||
|
|
||||||
|
|||||||
+21
-15
@@ -203,21 +203,31 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
|
|||||||
let retransmit_state = Arc::clone(&state);
|
let retransmit_state = Arc::clone(&state);
|
||||||
let retransmit_socket = Arc::clone(&socket);
|
let retransmit_socket = Arc::clone(&socket);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(Duration::from_millis(20));
|
let mut interval = tokio::time::interval(Duration::from_millis(100));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Err(err) = retransmit_pending(&retransmit_state, &retransmit_socket).await {
|
if let Err(err) = retransmit_pending(&retransmit_state, &retransmit_socket).await {
|
||||||
eprintln!("retransmit error: {err:#}");
|
eprintln!("retransmit error: {err:#}");
|
||||||
}
|
}
|
||||||
if let Err(err) = flush_paced_snapshots(&retransmit_state, &retransmit_socket).await {
|
|
||||||
eprintln!("paced snapshot error: {err:#}");
|
|
||||||
}
|
|
||||||
if let Err(err) = maybe_rekey_clients(&retransmit_state, &retransmit_socket).await {
|
if let Err(err) = maybe_rekey_clients(&retransmit_state, &retransmit_socket).await {
|
||||||
eprintln!("rekey error: {err:#}");
|
eprintln!("rekey error: {err:#}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let pacing_state = Arc::clone(&state);
|
||||||
|
let pacing_socket = Arc::clone(&socket);
|
||||||
|
let pacing_interval_ms = config.output_frame_interval_ms.max(1);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_millis(pacing_interval_ms));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(err) = flush_paced_snapshots(&pacing_state, &pacing_socket).await {
|
||||||
|
eprintln!("paced snapshot error: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let cleanup_state = Arc::clone(&state);
|
let cleanup_state = Arc::clone(&state);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||||
@@ -491,16 +501,12 @@ impl ServerState {
|
|||||||
|
|
||||||
/// Whether a session named `name` should be backed by a persistent holder.
|
/// Whether a session named `name` should be backed by a persistent holder.
|
||||||
///
|
///
|
||||||
/// Persistence is only worthwhile for sessions a user can actually reattach
|
/// With persistence enabled, every PTY session gets a detached holder. That
|
||||||
/// to by name: prewarmed sessions and explicitly-named ones (`--session`).
|
/// includes implicit `term-<millis>-<pid>` sessions: a reconnecting client has
|
||||||
/// Implicit `term-<millis>-<pid>` names (from `dosh host` with no session)
|
/// the exact session name in its ticket cache, so it can reattach after a
|
||||||
/// are ephemeral — persisting them just leaves unreattachable holders
|
/// quick server restart without forcing users to name sessions manually.
|
||||||
/// lingering across restarts, so they use the in-process (dies-on-restart)
|
fn should_persist_session(&self, _name: &str) -> bool {
|
||||||
/// shell that reaps normally on disconnect.
|
|
||||||
fn should_persist_session(&self, name: &str) -> bool {
|
|
||||||
self.config.persist_sessions
|
self.config.persist_sessions
|
||||||
&& (self.config.prewarm_sessions.iter().any(|s| s == name)
|
|
||||||
|| !protocol::is_implicit_session_name(name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn (or, in the persistent case, spawn-and-adopt) a PTY for a session.
|
/// Spawn (or, in the persistent case, spawn-and-adopt) a PTY for a session.
|
||||||
@@ -3209,7 +3215,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persists_named_and_prewarmed_but_not_implicit_sessions() {
|
fn persists_all_sessions_when_enabled() {
|
||||||
let (pty_tx, _rx) = mpsc::unbounded_channel();
|
let (pty_tx, _rx) = mpsc::unbounded_channel();
|
||||||
let config = ServerConfig {
|
let config = ServerConfig {
|
||||||
persist_sessions: true,
|
persist_sessions: true,
|
||||||
@@ -3219,7 +3225,7 @@ mod tests {
|
|||||||
let state = ServerState::new(config, [0u8; 32], pty_tx);
|
let state = ServerState::new(config, [0u8; 32], pty_tx);
|
||||||
assert!(state.should_persist_session("default")); // prewarmed
|
assert!(state.should_persist_session("default")); // prewarmed
|
||||||
assert!(state.should_persist_session("work")); // explicitly named
|
assert!(state.should_persist_session("work")); // explicitly named
|
||||||
assert!(!state.should_persist_session("term-1781470634216-76685")); // implicit
|
assert!(state.should_persist_session("term-1781470634216-76685")); // implicit reconnect
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+4
-11
@@ -48,16 +48,9 @@ pub struct ServerConfig {
|
|||||||
pub allow_agent_forwarding: bool,
|
pub allow_agent_forwarding: bool,
|
||||||
#[serde(default = "default_accept_env")]
|
#[serde(default = "default_accept_env")]
|
||||||
pub accept_env: Vec<String>,
|
pub accept_env: Vec<String>,
|
||||||
/// Run each terminal session's shell under a small per-session *holder*
|
/// Run terminal sessions under per-session holder processes so shells
|
||||||
/// process so it survives a `dosh-server` restart (crash/upgrade/
|
/// survive a quick `dosh-server` restart and clients can reconnect.
|
||||||
/// `systemctl restart`). On startup the server re-adopts live holders and
|
#[serde(default = "default_true")]
|
||||||
/// reattaching clients land on the same shell with screen state intact. When
|
|
||||||
/// `false`, sessions behave exactly as before: the shell is a child of the
|
|
||||||
/// server and dies with it.
|
|
||||||
// Opt-in for now: this changes the core session model (per-session holder
|
|
||||||
// processes + fd passing), so it stays OFF by default until it has been
|
|
||||||
// stress-tested on a real host. Set `persist_sessions = true` to enable.
|
|
||||||
#[serde(default)]
|
|
||||||
pub persist_sessions: bool,
|
pub persist_sessions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +83,7 @@ impl Default for ServerConfig {
|
|||||||
allow_remote_non_loopback_bind: false,
|
allow_remote_non_loopback_bind: false,
|
||||||
allow_agent_forwarding: false,
|
allow_agent_forwarding: false,
|
||||||
accept_env: default_accept_env(),
|
accept_env: default_accept_env(),
|
||||||
persist_sessions: false,
|
persist_sessions: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2338,6 +2338,72 @@ fn session_survives_server_restart_same_shell_and_screen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn implicit_session_survives_server_restart_for_same_ticket_holder() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sessions_dir = dir.path().join("sessions");
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_persistent_server_config(&dir, port);
|
||||||
|
let session = protocol::generate_implicit_session_name();
|
||||||
|
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let (socket, bootstrap, ok) = direct_attach_session(&config, port, "read-write", &session);
|
||||||
|
let key = bootstrap.session_key;
|
||||||
|
type_line(
|
||||||
|
&socket,
|
||||||
|
port,
|
||||||
|
&ok,
|
||||||
|
&key,
|
||||||
|
2,
|
||||||
|
"export IMPLICIT_MARK=implicit_restart_42\n",
|
||||||
|
);
|
||||||
|
let _ = collect_frame_text(&socket, &key, 1000);
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
|
||||||
|
let shell_pid_before = holder_shell_pid(&sessions_dir, &session);
|
||||||
|
assert!(
|
||||||
|
shell_pid_before.is_some(),
|
||||||
|
"implicit sessions should get a persistent holder when persistence is enabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
|
||||||
|
let pid = shell_pid_before.unwrap();
|
||||||
|
assert!(
|
||||||
|
unsafe { libc::kill(pid, 0) } == 0,
|
||||||
|
"implicit session shell pid {pid} must survive server restart"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let (socket2, bootstrap2, ok2) = direct_attach_session(&config, port, "read-write", &session);
|
||||||
|
let key2 = bootstrap2.session_key;
|
||||||
|
type_line(
|
||||||
|
&socket2,
|
||||||
|
port,
|
||||||
|
&ok2,
|
||||||
|
&key2,
|
||||||
|
2,
|
||||||
|
"printf 'IMPLICIT_MARK=%s\\n' \"$IMPLICIT_MARK\"\n",
|
||||||
|
);
|
||||||
|
let post = collect_frame_text(&socket2, &key2, 2000);
|
||||||
|
let shell_pid_after = holder_shell_pid(&sessions_dir, &session);
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
kill_holder(&sessions_dir, &session);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
shell_pid_after, shell_pid_before,
|
||||||
|
"implicit session should re-adopt the same shell pid"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
post.contains("IMPLICIT_MARK=implicit_restart_42"),
|
||||||
|
"implicit session state must survive restart, got {post:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_persistent_named_sessions_survive_restart_independently() {
|
fn multiple_persistent_named_sessions_survive_restart_independently() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user