From 26532fc0e14e6e458400f8480c6555696eda2c33 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:36:44 -0400 Subject: [PATCH] Stop idle keepalives from repainting terminals --- src/bin/dosh-client.rs | 9 +- tests/client_terminal_runtime.rs | 163 +++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 3 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index e12d827..a655e47 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -10676,9 +10676,12 @@ const TERMINAL_SNAPSHOT_RESET: &[u8] = concat!( .as_bytes(); /// Seconds of silence from the server before the disconnect status line appears. -/// Short enough to give quick feedback on a lost link, long enough that a normal -/// idle period (the run loop only pings after 2s of quiet) never flashes it. -const DISCONNECT_STATUS_THRESHOLD_SECS: u64 = 2; +/// Short enough to give quick feedback on a lost link, but later than the first +/// idle ping. Using the same threshold as the first ping races its Pong: the bar +/// is painted just before the authenticated reply arrives, which then requests a +/// snapshot to restore row 1 and turns every healthy idle connection into a +/// periodic reconnect loop. +const DISCONNECT_STATUS_THRESHOLD_SECS: u64 = 3; /// Mosh-style disconnect status bar with snapshot-backed restoration. /// diff --git a/tests/client_terminal_runtime.rs b/tests/client_terminal_runtime.rs index c47d81c..42e1a99 100644 --- a/tests/client_terminal_runtime.rs +++ b/tests/client_terminal_runtime.rs @@ -50,6 +50,7 @@ struct CachedCredentialWire { enum ServerObservation { BulkSent, Input(Vec), + Ping, Reconnected, RenderResynced, Resize(u16, u16), @@ -300,6 +301,87 @@ fn idle_reconnect_restores_snapshot_and_orders_reordered_frames() { assert!(status.success(), "Dosh client exited with {status:?}"); } +#[test] +fn authenticated_idle_pongs_do_not_trigger_snapshot_reconnects() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("home"); + let cache = dir.path().join("credentials"); + fs::create_dir_all(home.join(".config/dosh")).unwrap(); + fs::create_dir_all(&cache).unwrap(); + + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let port = socket.local_addr().unwrap().port(); + write_client_fixture(&home, &cache, port, 5); + let config_path = home.join(".config/dosh/client.toml"); + let mut config: ClientConfig = + toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + config.disconnect_status = true; + fs::write(&config_path, toml::to_string(&config).unwrap()).unwrap(); + + let (observation_tx, observation_rx) = mpsc::channel(); + let server = thread::spawn(move || run_idle_keepalive_server(socket, observation_tx)); + + let pty = NativePtySystem::default(); + let pair = pty + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut command = client_command(dir.path(), port); + command.env("HOME", home.to_string_lossy().to_string()); + command.env("USERPROFILE", home.to_string_lossy().to_string()); + command.env("APPDATA", home.to_string_lossy().to_string()); + command.env("LOCALAPPDATA", home.to_string_lossy().to_string()); + command.env("TERM", "xterm-256color"); + let mut child = pair.slave.spawn_command(command).unwrap(); + drop(pair.slave); + + let output = Arc::new(Mutex::new(Vec::new())); + let reader_output = Arc::clone(&output); + let reader_thread = thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => reader_output.lock().unwrap().extend_from_slice(&buf[..n]), + } + } + }); + + let status = child.wait().unwrap(); + drop(pair.master); + reader_thread.join().unwrap(); + server.join().unwrap(); + assert!(status.success(), "Dosh client exited with {status:?}"); + + let observations: Vec<_> = observation_rx.try_iter().collect(); + assert!( + observations + .iter() + .filter(|event| matches!(event, ServerObservation::Ping)) + .count() + >= 2, + "idle session did not exercise repeated authenticated keepalives: {observations:?}" + ); + assert!( + !observations + .iter() + .any(|event| matches!(event, ServerObservation::Reconnected)), + "healthy idle pongs caused a snapshot reconnect: {observations:?}" + ); + assert!( + !contains(&output.lock().unwrap(), b"[dosh] reconnecting"), + "healthy idle session flashed the disconnect overlay" + ); +} + #[test] fn renderer_overflow_resyncs_without_dropping_the_session() { const PROBE: &[u8] = b"DOSH_OVERFLOW_INPUT\r"; @@ -713,6 +795,87 @@ fn run_reconnect_server( } } +fn run_idle_keepalive_server(socket: UdpSocket, observations: mpsc::Sender) { + let started = Instant::now(); + let mut peer = None; + let mut attached = false; + let mut server_seq = 1u64; + let mut buf = [0u8; 65535]; + loop { + if started.elapsed() >= Duration::from_secs(7) + && let Some(source) = peer + { + server_seq += 1; + send_frame( + &socket, + source, + PacketKind::Frame, + server_seq, + 11, + b"DOSH_IDLE_KEEPALIVE_DONE", + false, + true, + ); + break; + } + + let (n, source) = match socket.recv_from(&mut buf) { + Ok(value) => value, + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue; + } + Err(err) => panic!("idle keepalive server receive failed: {err}"), + }; + let packet = protocol::decode(&buf[..n]).unwrap(); + match packet.header.kind { + PacketKind::ResumeRequest => { + let plain = + protocol::decrypt_body(&packet, &SESSION_KEY, CLIENT_TO_SERVER).unwrap(); + let request: protocol::ResumeRequest = protocol::from_body(&plain).unwrap(); + assert_eq!(request.session, SESSION); + if attached { + observations.send(ServerObservation::Reconnected).unwrap(); + } + attached = true; + peer = Some(source); + send_frame( + &socket, + source, + PacketKind::ResumeOk, + server_seq, + 10, + b"DOSH_IDLE_KEEPALIVE_READY", + true, + false, + ); + } + PacketKind::Ping => { + protocol::decrypt_body(&packet, &SESSION_KEY, CLIENT_TO_SERVER).unwrap(); + observations.send(ServerObservation::Ping).unwrap(); + server_seq += 1; + let pong = protocol::encode_encrypted( + PacketKind::Pong, + CLIENT_ID, + server_seq, + packet.header.seq, + &SESSION_KEY, + SERVER_TO_CLIENT, + b"", + ) + .unwrap(); + socket.send_to(&pong, source).unwrap(); + } + PacketKind::Ack => {} + _ => {} + } + } +} + fn run_overflow_server( socket: UdpSocket, observations: mpsc::Sender,