From e69be4fdf0508cd755eb8e7f4709a59efe1a639b Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:01:46 -0400 Subject: [PATCH] Reconnect before input after local sleep --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/bin/dosh-client.rs | 136 +++++++++++++++++++++++++++++++++++------ 3 files changed, 119 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac38b49..91fb275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "dosh" -version = "1.0.0-rc34" +version = "1.0.0-rc35" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 3d58292..b70ac73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dosh" -version = "1.0.0-rc34" +version = "1.0.0-rc35" edition = "2024" license = "MIT" diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index b6d844a..9575ffe 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -71,7 +71,7 @@ const MAX_PENDING_USER_INPUT_BYTES: usize = 1024 * 1024; const STARTUP_INPUT_HOLD: Duration = Duration::from_millis(750); const POST_SUBMIT_ALL_INPUT_HOLD: Duration = Duration::from_millis(120); const STALE_TERMINAL_INPUT_AFTER: Duration = Duration::from_secs(2); -const POST_RECONNECT_STALE_INPUT_GRACE: Duration = Duration::from_secs(2); +const POST_RECONNECT_STALE_INPUT_GRACE: Duration = Duration::from_secs(5); const FOCUS_REPAINT_COOLDOWN: Duration = Duration::from_secs(1); const ALT_SCREEN_IDLE_REPAINT_AFTER: Duration = Duration::from_secs(15); const LOCAL_SLEEP_REPAINT_AFTER: Duration = Duration::from_secs(5); @@ -5666,8 +5666,62 @@ async fn run_terminal( dosh::trace::event("client.escape", &[]); break; } + let input_status_tick_gap = last_status_tick_at.elapsed(); + let mut refreshed_before_input = false; + if should_reconnect_before_input_for_local_sleep( + forward_only, + input_status_tick_gap, + ) { + dosh::trace::event( + "client.local_sleep_input_reconnect_start", + &[( + "tick_gap_ms", + input_status_tick_gap.as_millis().to_string(), + )], + ); + last_status_tick_at = Instant::now(); + arm_stale_terminal_input_suppression( + &mut stale_terminal_input_suppress_until, + ); + if let Some(frame) = reconnect( + &socket, + &mut cred, + &mut send_seq, + last_size, + &mut frame_buffer, + &mut predictor, + ) + .await? + { + dosh::trace::event( + "client.local_sleep_input_reconnect_ok", + &[ + ("output_seq", frame.output_seq.to_string()), + ("bytes", frame.bytes.len().to_string()), + ], + ); + refresh_live_addr(&mut addr, &cred)?; + render_frame(&frame)?; + predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); + last_packet_at = Instant::now(); + last_focus_repaint_at = Instant::now(); + refreshed_before_input = true; + flush_pending_user_input( + &socket, + addr, + &cred, + &mut send_seq, + &mut predictor, + &mut pending_user_input, + &mut pending_user_input_bytes, + ) + .await?; + } + } let saw_focus_in = input_contains_focus_in(&bytes); if !forward_only + && !refreshed_before_input && saw_focus_in && last_focus_repaint_at.elapsed() >= FOCUS_REPAINT_COOLDOWN { @@ -7087,6 +7141,13 @@ fn should_strip_stale_terminal_reports( || suppress_until.is_some_and(|deadline| Instant::now() < deadline) } +fn should_reconnect_before_input_for_local_sleep( + forward_only: bool, + status_tick_gap: Duration, +) -> bool { + !forward_only && status_tick_gap >= LOCAL_SLEEP_REPAINT_AFTER +} + fn should_strip_unowned_terminal_reports(alternate_screen: bool, mouse_tracking: bool) -> bool { !alternate_screen && !mouse_tracking } @@ -7144,6 +7205,13 @@ fn should_repaint_idle_terminal( fn arm_stale_terminal_input_suppression(suppress_until: &mut Option) { *suppress_until = Some(Instant::now() + POST_RECONNECT_STALE_INPUT_GRACE); + dosh::trace::event( + "client.stale_terminal_quarantine_arm", + &[( + "grace_ms", + POST_RECONNECT_STALE_INPUT_GRACE.as_millis().to_string(), + )], + ); } async fn flush_pending_user_input( @@ -9083,24 +9151,25 @@ mod tests { use super::{ ALT_SCREEN_IDLE_REPAINT_AFTER, CachedCredential, DisconnectStatus, DynamicForward, FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer, LOCAL_SLEEP_REPAINT_AFTER, LocalForward, - MAX_PENDING_USER_INPUT_BYTES, NativeIdentityContext, POST_SUBMIT_ALL_INPUT_HOLD, - PendingStreamOpen, PredictMode, Predictor, RESTART_STATUS_SCRIPT, RemoteForward, - STARTUP_INPUT_HOLD, SshConfig, SshPathTokenContext, StartupGateMode, StatusAction, - UpdateOptions, UpdateRole, auth_allows, cache_key, cache_server_prefix, - cleanup_stream_state, clear_cached_credentials, ensure_tui_safe_status_overlay, - expand_ssh_path_tokens, input_contains_focus_in, input_matches_escape, - is_local_status_target, is_resume_response_for_client, latest_release_download_url, - load_first_native_identity_with_prompt, native_proxy_udp_warning, parse_dynamic_forward, - parse_escape_key, parse_local_forward, parse_remote_forward, parse_ssh_config, - parse_trace_line, parse_trace_options, parse_trace_report_options, parse_trace_summary, - parse_update_options, post_submit_hold_duration, queue_pending_user_input, - queue_stale_pending_user_input, raw_contains_host_table, recv_response_until, - refresh_live_addr, release_tag_download_url, release_tag_from_effective_url, - release_version_from_tag, render_status_clear, render_status_overlay, requested_env, - resolved_startup_command, retire_stream_state, retransmit_stream_opens, - rewrite_forward_command, sanitize_trace_name, selected_predict_mode, selected_udp_host, - server_version_mismatch, should_flush_terminal_input_after_contact, - should_hold_during_startup_gate, should_hold_post_submit_input, + MAX_PENDING_USER_INPUT_BYTES, NativeIdentityContext, POST_RECONNECT_STALE_INPUT_GRACE, + POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode, Predictor, + RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig, SshPathTokenContext, + StartupGateMode, StatusAction, UpdateOptions, UpdateRole, auth_allows, cache_key, + cache_server_prefix, cleanup_stream_state, clear_cached_credentials, + ensure_tui_safe_status_overlay, expand_ssh_path_tokens, input_contains_focus_in, + input_matches_escape, is_local_status_target, is_resume_response_for_client, + latest_release_download_url, load_first_native_identity_with_prompt, + native_proxy_udp_warning, parse_dynamic_forward, parse_escape_key, parse_local_forward, + parse_remote_forward, parse_ssh_config, parse_trace_line, parse_trace_options, + parse_trace_report_options, parse_trace_summary, parse_update_options, + post_submit_hold_duration, queue_pending_user_input, queue_stale_pending_user_input, + raw_contains_host_table, recv_response_until, refresh_live_addr, release_tag_download_url, + release_tag_from_effective_url, release_version_from_tag, render_status_clear, + render_status_overlay, requested_env, resolved_startup_command, retire_stream_state, + retransmit_stream_opens, rewrite_forward_command, sanitize_trace_name, + selected_predict_mode, selected_udp_host, server_version_mismatch, + should_flush_terminal_input_after_contact, should_hold_during_startup_gate, + should_hold_post_submit_input, should_reconnect_before_input_for_local_sleep, should_repaint_idle_terminal, should_strip_unowned_terminal_reports, split_after_command_submit, split_trace_tokens, ssh_command_target, ssh_config_uses_proxy, ssh_destination_host, ssh_username, ssh_with_user, startup_command, status_ssh_target, @@ -11134,6 +11203,35 @@ mod tests { )); } + #[test] + fn local_sleep_gap_reconnects_before_forwarding_input() { + assert!(should_reconnect_before_input_for_local_sleep( + false, + LOCAL_SLEEP_REPAINT_AFTER + )); + assert!(should_reconnect_before_input_for_local_sleep( + false, + LOCAL_SLEEP_REPAINT_AFTER + Duration::from_secs(10) + )); + assert!(!should_reconnect_before_input_for_local_sleep( + false, + LOCAL_SLEEP_REPAINT_AFTER - Duration::from_millis(1) + )); + assert!(!should_reconnect_before_input_for_local_sleep( + true, + LOCAL_SLEEP_REPAINT_AFTER + Duration::from_secs(10) + )); + } + + #[test] + fn reconnect_mouse_quarantine_is_long_enough_for_sleep_wake_noise() { + assert!(POST_RECONNECT_STALE_INPUT_GRACE >= LOCAL_SLEEP_REPAINT_AFTER); + assert_eq!( + strip_stale_mouse_reports(b"35;152;1M\x1b[A35;149;1M"), + b"\x1b[A" + ); + } + #[test] fn non_mouse_escape_input_survives_stale_filter() { assert_eq!(strip_stale_mouse_reports(b"\x1b[A"), b"\x1b[A");