From f6ead86db4e78f518970a4a1a8f21cf7109b00a2 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:18:34 -0400 Subject: [PATCH] Add setup selftest and diagnostics polish --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 27 ++- install.ps1 | 7 +- install.sh | 11 +- src/bin/dosh-client.rs | 406 ++++++++++++++++++++++++++++++++++++++--- 6 files changed, 424 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55dc1a9..17f95df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "dosh" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 6b2b314..cacc953 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dosh" -version = "0.1.0" +version = "0.1.1" edition = "2024" license = "MIT" diff --git a/README.md b/README.md index fcff91b..c414776 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,10 @@ dosh --version `dosh update` first tries a release tarball named for the platform (`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest -Gitea/GitHub release. If that asset is not published yet, it falls back to the -source build path. If the release also publishes `.sha256`, the installer -verifies the archive before installing it. +Gitea/GitHub release. Gitea's concrete tag download route is detected when +`/releases/latest/download/...` is not supported. If that asset is not published +yet, it falls back to the source build path. If the release also publishes +`.sha256`, the installer verifies the archive before installing it. Install the client on Windows PowerShell: @@ -139,6 +140,19 @@ Attach: dosh user@server.example.com ``` +First-time setup for an existing SSH alias: + +```bash +dosh setup homelab +dosh selftest homelab +dosh homelab +``` + +`dosh setup ` imports the OpenSSH alias into +`~/.config/dosh/hosts.toml`, pins the Dosh host key over SSH, runs `dosh doctor`, +and prints next commands. `dosh selftest ` checks local terminal overlay +safety, forwarding syntax, and the remote doctor path. + Plain `dosh user@server.example.com` opens a fresh terminal session. Use named sessions when you want to reattach to the same persistent terminal from multiple clients: @@ -165,6 +179,13 @@ If SSH and UDP use different public names, specify the UDP address: dosh-client --dosh-host public.example.com --dosh-port 50000 user@host ``` +Forwarding can use SSH-shaped flags directly or the forward-only wrapper: + +```bash +dosh -N -L 8080:127.0.0.1:80 homelab +dosh forward homelab -L 8080:127.0.0.1:80 -D 1080 +``` + Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts, ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit diff --git a/install.ps1 b/install.ps1 index bcc124b..708a909 100644 --- a/install.ps1 +++ b/install.ps1 @@ -200,7 +200,12 @@ Write-Host "Installed Dosh client to $bindir" Write-Host "Configured UDP port $Port" Write-Host "" $displayServer = if ($Server) { $Server } else { "user@host" } -Write-Host "Client command:" +Write-Host "Client commands:" Write-Host " $bindir\dosh.exe $displayServer" +Write-Host " $bindir\dosh.exe setup " +Write-Host " $bindir\dosh.exe update --check" +Write-Host "" +Write-Host "Client config:" +Write-Host " $configDir\client.toml" Write-Host "" Write-Host "Open a new terminal for PATH changes to apply." diff --git a/install.sh b/install.sh index 614c778..43cc8fc 100755 --- a/install.sh +++ b/install.sh @@ -527,10 +527,17 @@ Configured UDP port $port EOF if [ "$role" = "client" ] || [ "$role" = "both" ]; then + next_server="${server:-user@host}" cat < + $bindir/dosh update --check + +Client config: + $config_dir/client.toml + $config_dir/hosts.toml EOF fi diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 30dd9f1..29a789c 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -66,7 +66,7 @@ fn terminal_size() -> (u16, u16) { } } -#[derive(Debug, Parser)] +#[derive(Debug, Clone, Parser)] #[command(name = "dosh-client", version)] struct Args { #[arg()] @@ -189,11 +189,20 @@ struct PendingStreamChunk { #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { - let args = Args::parse(); + let mut args = Args::parse(); let config = load_client_config(None).unwrap_or_default(); if args.server.as_deref() == Some("update") { return run_update(&config, update_check_requested(&args.command)?); } + if args.server.as_deref() == Some("setup") { + return run_setup_command(&config, &args).await; + } + if args.server.as_deref() == Some("selftest") { + return run_selftest_command(&config, &args).await; + } + if args.server.as_deref() == Some("forward") { + args = rewrite_forward_command(args)?; + } if args.server.as_deref() == Some("import-ssh") { return run_import_ssh(&config, &args.command); } @@ -587,15 +596,113 @@ fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result Ok(()) } +async fn run_setup_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { + let host = args + .command + .first() + .cloned() + .ok_or_else(|| anyhow!("usage: dosh setup "))?; + println!("Dosh setup for {host}"); + + let imported = import_ssh_aliases(config, std::slice::from_ref(&host))?; + for entry in imported { + match entry.action { + ImportAction::Added => println!( + "[ok] host config: [{}] ssh={} dosh_host={} port={}", + entry.alias, entry.ssh, entry.dosh_host, entry.port + ), + ImportAction::Skipped => println!("[ok] host config: [{}] already exists", entry.alias), + } + } + + let hosts = load_hosts_config(None).unwrap_or_default(); + let host_config = hosts.hosts.get(&host).cloned().unwrap_or_default(); + let raw_server = host_config.ssh.clone().unwrap_or_else(|| host.clone()); + let server = ssh_with_user(&raw_server, host_config.user.as_deref()); + let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); + let ssh_auth_command = args + .ssh_auth_command + .clone() + .or_else(|| config.ssh_auth_command.clone()) + .unwrap_or_else(|| "~/.local/bin/dosh-auth".to_string()); + let known_hosts = expand_tilde(&config.known_hosts); + let public_key = fetch_host_key_over_ssh( + &server, + ssh_port, + &ssh_auth_command, + args.ssh_key.as_deref(), + args.ssh_known_hosts.as_deref(), + args.ssh_control_path.as_deref(), + ) + .with_context(|| { + format!("fetch Dosh host key over SSH; make sure dosh-auth is installed on {server}") + })?; + match trust_host(&known_hosts, &host, &public_key, "ssh", args.replace)? { + TrustResult::AlreadyTrusted => println!( + "[ok] trust: already trusted {}", + host_fingerprint(&public_key) + ), + TrustResult::Trusted => println!( + "[ok] trust: pinned {} in {}", + host_fingerprint(&public_key), + known_hosts.display() + ), + } + + println!(); + run_doctor_for_host(config, args, &host).await?; + println!(); + println!("Next:"); + println!(" dosh {host}"); + println!(" dosh update --check"); + Ok(()) +} + +async fn run_selftest_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { + let host = args + .command + .first() + .cloned() + .ok_or_else(|| anyhow!("usage: dosh selftest "))?; + println!("Dosh selftest for {host}"); + println!("[ok] terminal size fallback: {:?}", terminal_size()); + + 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"); + + parse_local_forward("18080:127.0.0.1:80")?; + parse_remote_forward("19090:127.0.0.1:5432")?; + parse_dynamic_forward("11080")?; + println!("[ok] forwarding parser: -L, -R, -D"); + + println!(); + run_doctor_for_host(config, args, &host).await?; + println!(); + println!("[ok] selftest complete"); + Ok(()) +} + async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { let requested = args .command .first() .cloned() .ok_or_else(|| anyhow!("usage: dosh doctor "))?; + run_doctor_for_host(config, args, &requested).await +} + +async fn run_doctor_for_host( + config: &dosh::config::ClientConfig, + args: &Args, + requested: &str, +) -> Result<()> { let hosts = load_hosts_config(None).unwrap_or_default(); - let host_config = hosts.hosts.get(&requested).cloned().unwrap_or_default(); - let raw_server = host_config.ssh.clone().unwrap_or_else(|| requested.clone()); + let host_config = hosts.hosts.get(requested).cloned().unwrap_or_default(); + let raw_server = host_config + .ssh + .clone() + .unwrap_or_else(|| requested.to_string()); let server = ssh_with_user(&raw_server, host_config.user.as_deref()); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); let dosh_port = args @@ -603,6 +710,20 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> .or(host_config.port) .unwrap_or(config.dosh_port); println!("Dosh doctor for {requested}"); + println!( + "[info] config: client={} hosts={} known_hosts={}", + expand_tilde("~/.config/dosh/client.toml").display(), + expand_tilde("~/.config/dosh/hosts.toml").display(), + expand_tilde(&config.known_hosts).display() + ); + println!( + "[info] client: auth={} predict={}({}) disconnect_status={} cache_tickets={}", + config.auth_preference, + config.predict, + config.predict_mode, + config.disconnect_status, + config.cache_attach_tickets + ); let parsed_ssh_config = match ssh_config(&server, ssh_port) { Ok(parsed) => { @@ -652,6 +773,14 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> .unwrap_or_else(|| ssh_destination_host(&server)) }); println!("[info] native endpoint: {udp_host}:{dosh_port}"); + match resolve_addr(&udp_host, dosh_port) { + Ok(addr) => println!("[ok] udp resolve: {addr}"), + Err(err) => println!("[fail] udp resolve: {err:#}"), + } + println!( + "[info] forwarding requested by config: agent={} default_session={}", + config.forward_agent, config.default_session + ); let socket = UdpSocket::bind("0.0.0.0:0").await?; match try_native_auth_check( &socket, @@ -693,6 +822,26 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> } } +fn ensure_tui_safe_status_overlay(bytes: &[u8]) -> Result<()> { + anyhow::ensure!( + bytes.starts_with(b"\x1b7"), + "status overlay must save cursor" + ); + anyhow::ensure!( + bytes.ends_with(b"\x1b8"), + "status overlay must restore cursor" + ); + anyhow::ensure!( + !contains_bytes(bytes, b"\n") && !contains_bytes(bytes, b"\r"), + "status overlay must not scroll" + ); + anyhow::ensure!( + !contains_bytes(bytes, b"\x1b[?25l") && !contains_bytes(bytes, b"\x1b[?25h"), + "status overlay must not toggle cursor visibility" + ); + Ok(()) +} + fn ssh_fallback_probe( server: &str, ssh_port: Option, @@ -905,6 +1054,63 @@ fn spawn_background_forwarder(args: &Args, forwarding_requested: bool) -> Result } } +fn rewrite_forward_command(mut args: Args) -> Result { + let mut tokens = std::mem::take(&mut args.command).into_iter(); + let host = tokens.next().ok_or_else(|| { + anyhow!("usage: dosh forward [-L spec] [-R spec] [-D spec] [-A] [-f]") + })?; + let mut trailing_command = Vec::new(); + while let Some(token) = tokens.next() { + match token.as_str() { + "-L" | "--local-forward" => { + let spec = tokens + .next() + .ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?; + args.local_forward.push(spec); + } + "-R" | "--remote-forward" => { + let spec = tokens + .next() + .ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?; + args.remote_forward.push(spec); + } + "-D" | "--dynamic-forward" => { + let spec = tokens + .next() + .ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?; + args.dynamic_forward.push(spec); + } + "-A" | "--forward-agent" => args.forward_agent = true, + "-f" | "--background" => args.background = true, + "-N" | "--forward-only" => args.forward_only = true, + "--" => { + trailing_command.extend(tokens); + break; + } + other => return Err(anyhow!("dosh forward: unknown option {other:?}")), + } + } + if !trailing_command.is_empty() { + return Err(anyhow!( + "dosh forward does not run remote commands; remove {:?}", + trailing_command + )); + } + if args.local_forward.is_empty() + && args.remote_forward.is_empty() + && args.dynamic_forward.is_empty() + && !args.forward_agent + { + return Err(anyhow!( + "dosh forward requires at least one -L, -R, -D, or -A request" + )); + } + args.server = Some(host); + args.command = Vec::new(); + args.forward_only = true; + Ok(args) +} + fn signal_background_ready() { let Some(addr) = std::env::var_os("DOSH_BACKGROUND_READY_ADDR") else { return; @@ -958,12 +1164,20 @@ fn parse_local_forward(raw: &str) -> Result { let target_port = target_port .parse::() .with_context(|| format!("invalid local forward target port in {raw:?}"))?; - if target_host.is_empty() { + if !valid_forward_host(target_host) { return Err(anyhow!("invalid -L {raw:?}; target host cannot be empty")); } - if bind_host.is_empty() { + if !valid_forward_host(&bind_host) { return Err(anyhow!("invalid -L {raw:?}; bind host cannot be empty")); } + anyhow::ensure!( + listen_port != 0, + "invalid -L {raw:?}; listen port cannot be 0" + ); + anyhow::ensure!( + target_port != 0, + "invalid -L {raw:?}; target port cannot be 0" + ); Ok(LocalForward { bind_host, listen_port, @@ -996,15 +1210,26 @@ fn parse_dynamic_forward(raw: &str) -> Result { let listen_port = listen .parse::() .with_context(|| format!("invalid dynamic forward listen port in {raw:?}"))?; - if bind_host.is_empty() { + if !valid_forward_host(&bind_host) { return Err(anyhow!("invalid -D {raw:?}; bind host cannot be empty")); } + anyhow::ensure!( + listen_port != 0, + "invalid -D {raw:?}; listen port cannot be 0" + ); Ok(DynamicForward { bind_host, listen_port, }) } +fn valid_forward_host(host: &str) -> bool { + !host.is_empty() + && !host + .bytes() + .any(|byte| byte == 0 || byte == b'\n' || byte == b'\r' || byte == b'\t') +} + fn forwarding_requests( local_forwards: &[LocalForward], remote_forwards: &[RemoteForward], @@ -1213,10 +1438,51 @@ fn run_update(config: &dosh::config::ClientConfig, check_only: bool) -> Result<( Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq)] +enum ImportAction { + Added, + Skipped, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ImportedHost { + alias: String, + ssh: String, + dosh_host: String, + port: u16, + action: ImportAction, +} + fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Result<()> { if aliases.is_empty() { return Err(anyhow!("usage: dosh import-ssh [ssh-host...]")); } + let imported = import_ssh_aliases(config, aliases)?; + for entry in imported { + match entry.action { + ImportAction::Added => eprintln!( + "dosh import-ssh: added [{}] ssh={} dosh_host={}", + entry.alias, entry.ssh, entry.dosh_host + ), + ImportAction::Skipped => { + eprintln!( + "dosh import-ssh: [{}] already exists, skipping", + entry.alias + ) + } + } + } + eprintln!( + "dosh import-ssh: wrote {}", + expand_tilde("~/.config/dosh/hosts.toml").display() + ); + Ok(()) +} + +fn import_ssh_aliases( + config: &dosh::config::ClientConfig, + aliases: &[String], +) -> Result> { let path = expand_tilde("~/.config/dosh/hosts.toml"); let mut raw = if path.exists() { fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))? @@ -1226,6 +1492,7 @@ fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Re if !raw.ends_with('\n') && !raw.is_empty() { raw.push('\n'); } + let mut imported = Vec::new(); for alias in aliases { let ssh_config = ssh_config(alias, None) .with_context(|| format!("read SSH config for {alias} with ssh -G"))?; @@ -1234,7 +1501,13 @@ fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Re .clone() .unwrap_or_else(|| ssh_destination_host(alias)); if raw_contains_host_table(&raw, alias) { - eprintln!("dosh import-ssh: [{alias}] already exists, skipping"); + imported.push(ImportedHost { + alias: alias.clone(), + ssh: alias.clone(), + dosh_host: udp_host, + port: config.dosh_port, + action: ImportAction::Skipped, + }); continue; } raw.push('\n'); @@ -1251,13 +1524,19 @@ fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Re raw.push_str(&format!("ssh_port = {port}\n")); } raw.push_str("predict = true\n"); + imported.push(ImportedHost { + alias: alias.clone(), + ssh: alias.clone(), + dosh_host: udp_host, + port: config.dosh_port, + action: ImportAction::Added, + }); } if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?; - eprintln!("dosh import-ssh: wrote {}", path.display()); - Ok(()) + Ok(imported) } fn raw_contains_host_table(raw: &str, alias: &str) -> bool { @@ -4202,10 +4481,12 @@ impl DisconnectStatus { /// Pure: render the status text for a given elapsed silence. Kept separate /// from any I/O so it can be asserted in tests. fn render_text(elapsed: Duration) -> String { - format!( - "[dosh] last contact {}s ago — reconnecting…", - elapsed.as_secs() - ) + let secs = elapsed.as_secs(); + if secs >= 30 { + format!("[dosh] still reconnecting — last contact {secs}s ago") + } else { + format!("[dosh] reconnecting — last contact {secs}s ago") + } } /// Pure decision for a status timer tick: given how long the link has been @@ -4384,12 +4665,13 @@ const TERMINAL_CLEANUP: &[u8] = concat!( mod tests { use super::{ DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor, - RemoteForward, SshConfig, StatusAction, auth_allows, latest_release_download_url, - load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward, - parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until, - render_status_clear, render_status_overlay, requested_env, resolved_startup_command, - ssh_destination_host, ssh_username, ssh_with_user, startup_command, - toml_bare_key_or_quoted, update_check_requested, + RemoteForward, SshConfig, StatusAction, auth_allows, ensure_tui_safe_status_overlay, + latest_release_download_url, load_first_native_identity_with_prompt, parse_dynamic_forward, + parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table, + recv_response_until, render_status_clear, render_status_overlay, requested_env, + resolved_startup_command, rewrite_forward_command, ssh_destination_host, ssh_username, + ssh_with_user, startup_command, toml_bare_key_or_quoted, update_check_requested, + valid_forward_host, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; @@ -4397,6 +4679,37 @@ mod tests { use ed25519_dalek::{SigningKey, VerifyingKey}; use std::time::Duration; + fn test_args(server: &str, command: &[&str]) -> super::Args { + super::Args { + server: Some(server.to_string()), + command: command.iter().map(|value| value.to_string()).collect(), + session: None, + new: false, + view_only: false, + local_auth: false, + auth: None, + no_cache: false, + remove: false, + replace: false, + attach_only: false, + local_forward: Vec::new(), + remote_forward: Vec::new(), + dynamic_forward: Vec::new(), + forward_agent: false, + forward_only: false, + background: false, + predict: false, + ssh_port: None, + ssh_auth_command: None, + ssh_key: None, + ssh_known_hosts: None, + ssh_control_path: None, + dosh_port: None, + dosh_host: None, + verbose: 0, + } + } + #[test] fn parses_ssh_config_hostname() { let raw = "user alice\nhostname server.example.com\nport 22\n"; @@ -5105,6 +5418,40 @@ mod tests { ); } + #[test] + fn forward_command_rewrites_to_forward_only_connection() { + let args = test_args( + "forward", + &["homelab", "-L", "8080:127.0.0.1:80", "-D", "1080", "-f"], + ); + let rewritten = rewrite_forward_command(args).unwrap(); + + assert_eq!(rewritten.server.as_deref(), Some("homelab")); + assert!(rewritten.forward_only); + assert!(rewritten.background); + assert_eq!(rewritten.command, Vec::::new()); + assert_eq!(rewritten.local_forward, vec!["8080:127.0.0.1:80"]); + assert_eq!(rewritten.dynamic_forward, vec!["1080"]); + } + + #[test] + fn forward_command_requires_a_forward_request() { + let err = rewrite_forward_command(test_args("forward", &["homelab"])) + .unwrap_err() + .to_string(); + assert!(err.contains("requires at least one")); + } + + #[test] + fn forward_parser_rejects_zero_ports_and_control_chars() { + assert!(parse_local_forward("0:127.0.0.1:80").is_err()); + assert!(parse_local_forward("8080:127.0.0.1:0").is_err()); + assert!(parse_dynamic_forward("127.0.0.1:0").is_err()); + assert!(!valid_forward_host("bad\nhost")); + assert!(!valid_forward_host("bad\thost")); + assert!(valid_forward_host("server.example.com")); + } + // --- Item 1: disconnect status line state machine --- /// The status line stays hidden while the link is fresh and only appears once @@ -5121,7 +5468,7 @@ mod tests { let action = status.on_tick(Duration::from_secs(3)); assert_eq!( action, - StatusAction::Show("[dosh] last contact 3s ago — reconnecting…".to_string()) + StatusAction::Show("[dosh] reconnecting — last contact 3s ago".to_string()) ); status.applied(&action); assert!(status.shown); @@ -5140,7 +5487,7 @@ mod tests { // Next second -> repaint with updated text. assert_eq!( status.on_tick(Duration::from_secs(4)), - StatusAction::Show("[dosh] last contact 4s ago — reconnecting…".to_string()) + StatusAction::Show("[dosh] reconnecting — last contact 4s ago".to_string()) ); } @@ -5185,7 +5532,8 @@ mod tests { /// the application's cursor and full-screen TUIs are never disturbed. #[test] fn disconnect_status_overlay_is_save_restore_bottom_row() { - let bytes = render_status_overlay("[dosh] last contact 3s ago — reconnecting…", 24); + 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. @@ -5202,6 +5550,18 @@ mod tests { assert!(clear.windows(4).any(|w| w == b"\x1b[2K")); } + #[test] + fn disconnect_status_text_escalates_after_long_silence() { + assert_eq!( + DisconnectStatus::render_text(Duration::from_secs(29)), + "[dosh] reconnecting — last contact 29s ago" + ); + assert_eq!( + DisconnectStatus::render_text(Duration::from_secs(30)), + "[dosh] still reconnecting — last contact 30s ago" + ); + } + // --- Item 2: predictor policy re-evaluated on a timer tick --- /// A latency spike must surface predictions on a *timer tick*, not only on