diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index f0cc564..9661864 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -63,6 +63,8 @@ const STREAM_INITIAL_WINDOW: usize = 1024 * 1024; 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); /// Sentinel `target_host` the server uses on a server-initiated `StreamOpen` that /// represents an SSH-agent connection (rather than a TCP target). The client @@ -4376,6 +4378,7 @@ async fn run_terminal( let mut pending_user_input_bytes = 0usize; let mut startup_input_hold_until: Option = None; let mut startup_gate_mode = StartupGateMode::HoldControl; + let mut stale_terminal_input_suppress_until: Option = None; if let Some(frame) = first_frame { if !forward_only { render_frame(&frame)?; @@ -4433,10 +4436,19 @@ async fn run_terminal( biased; stdin_msg = stdin_rx.recv() => { match stdin_msg { - Some(bytes) => { + Some(mut bytes) => { if input_matches_escape(&bytes, escape_key.as_deref()) { break; } + if should_strip_stale_terminal_reports( + last_packet_at.elapsed(), + stale_terminal_input_suppress_until, + ) { + bytes = strip_stale_mouse_reports(&bytes); + if bytes.is_empty() { + continue; + } + } if cred.mode != "view-only" && cred.mode != "forward-only" { if let Some(deadline) = startup_input_hold_until { if !predictor.alternate_screen @@ -4535,6 +4547,9 @@ async fn run_terminal( .await? { refresh_live_addr(&mut addr, &cred)?; + arm_stale_terminal_input_suppression( + &mut stale_terminal_input_suppress_until, + ); if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); @@ -4594,6 +4609,9 @@ async fn run_terminal( .await? { refresh_live_addr(&mut addr, &cred)?; + arm_stale_terminal_input_suppression( + &mut stale_terminal_input_suppress_until, + ); if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); @@ -4644,6 +4662,9 @@ async fn run_terminal( .await? { refresh_live_addr(&mut addr, &cred)?; + arm_stale_terminal_input_suppression( + &mut stale_terminal_input_suppress_until, + ); if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); @@ -4765,6 +4786,9 @@ async fn run_terminal( .await? { refresh_live_addr(&mut addr, &cred)?; + arm_stale_terminal_input_suppression( + &mut stale_terminal_input_suppress_until, + ); if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); @@ -5157,6 +5181,9 @@ async fn run_terminal( .await? { refresh_live_addr(&mut addr, &cred)?; + arm_stale_terminal_input_suppression( + &mut stale_terminal_input_suppress_until, + ); if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); @@ -5562,6 +5589,18 @@ fn should_flush_terminal_input_after_contact( !forward_only && !pending_empty && mode != "view-only" && mode != "forward-only" } +fn should_strip_stale_terminal_reports( + silent_for: Duration, + suppress_until: Option, +) -> bool { + silent_for >= STALE_TERMINAL_INPUT_AFTER + || suppress_until.is_some_and(|deadline| Instant::now() < deadline) +} + +fn arm_stale_terminal_input_suppression(suppress_until: &mut Option) { + *suppress_until = Some(Instant::now() + POST_RECONNECT_STALE_INPUT_GRACE); +} + async fn flush_pending_user_input( socket: &UdpSocket, addr: SocketAddr, @@ -5623,6 +5662,7 @@ fn stale_mouse_report_len(bytes: &[u8], offset: usize) -> Option { match bytes.get(offset).copied()? { 0x1b if bytes.get(offset + 1) == Some(&b'[') => match bytes.get(offset + 2).copied() { Some(b'M') if offset + 6 <= bytes.len() => Some(6), + Some(b'I' | b'O') => Some(3), Some(b'<') => mouse_report_params_len(bytes, offset + 3, 2).map(|len| len + 3), Some(byte) if byte.is_ascii_digit() => { mouse_report_params_len(bytes, offset + 2, 2).map(|len| len + 2) @@ -5631,13 +5671,15 @@ fn stale_mouse_report_len(bytes: &[u8], offset: usize) -> Option { }, 0x9b => match bytes.get(offset + 1).copied() { Some(b'M') if offset + 5 <= bytes.len() => Some(5), + Some(b'I' | b'O') => Some(2), Some(b'<') => mouse_report_params_len(bytes, offset + 2, 2).map(|len| len + 2), Some(byte) if byte.is_ascii_digit() => { mouse_report_params_len(bytes, offset + 1, 2).map(|len| len + 1) } _ => None, }, - byte if byte.is_ascii_digit() => mouse_report_params_len(bytes, offset, 1), + byte if byte.is_ascii_digit() => mouse_report_params_len(bytes, offset, 1) + .or_else(|| orphan_mouse_suffix_len(bytes, offset)), _ => None, } } @@ -5659,6 +5701,21 @@ fn mouse_report_params_len(bytes: &[u8], offset: usize, min_semicolons: usize) - None } +fn orphan_mouse_suffix_len(bytes: &[u8], offset: usize) -> Option { + if offset != 0 { + return None; + } + let mut cursor = offset; + while let Some(byte) = bytes.get(cursor).copied() { + match byte { + b'0'..=b'9' => cursor += 1, + b'M' | b'm' if cursor > offset => return Some(cursor + 1 - offset), + _ => return None, + } + } + None +} + fn stale_mouse_report_maybe_incomplete(bytes: &[u8], offset: usize) -> bool { let Some(rest) = bytes.get(offset..) else { return false; @@ -8495,6 +8552,18 @@ mod tests { assert_eq!(strip_stale_mouse_reports(b"152;1Mls\r"), b"ls\r"); } + #[test] + fn split_stale_mouse_suffixes_are_stripped_from_pending_input() { + assert_eq!(strip_stale_mouse_reports(b"1M35;149;1Mls\r"), b"ls\r"); + assert_eq!(strip_stale_mouse_reports(b"10m"), b""); + } + + #[test] + fn stale_focus_reports_are_stripped_from_pending_input() { + assert_eq!(strip_stale_mouse_reports(b"\x1b[Ihello\x1b[O"), b"hello"); + assert_eq!(strip_stale_mouse_reports(b"\x9bIhello\x9bO"), b"hello"); + } + #[test] fn non_mouse_escape_input_survives_stale_filter() { assert_eq!(strip_stale_mouse_reports(b"\x1b[A"), b"\x1b[A");