From 7505c08d47d2ba9843066ecbc78ce46eccf0096a Mon Sep 17 00:00:00 2001 From: akbash Date: Sun, 13 Sep 2026 16:33:58 +0300 Subject: [PATCH] fix: retain validated split mouse reports across idle gaps (#4048) refs #3911 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig --- src/client/input.rs | 126 +++++++++++--- src/client/input/windows_vti.rs | 95 +++++++++- src/raw_input.rs | 296 ++++++++++++++++++++++++-------- 3 files changed, 412 insertions(+), 105 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index 456f59b5..c86f0878 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -365,31 +365,11 @@ fn windows_crossterm_reader_loop( Err(_) => break, }; - let raw_sequence_pending = framer.has_pending_input(); - if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { - tracing::debug!( - bytes = ?bytes, - pending_before = raw_sequence_pending, - "windows input routed through raw framer" - ); - if !send_windows_raw_events(framer.push(&bytes), &event_tx) { - return; - } - continue; + let (raw_events, event) = frame_windows_crossterm_event(&mut framer, event); + if !send_windows_raw_events(raw_events, &event_tx) { + return; } - - if raw_sequence_pending { - tracing::debug!("windows input raw sequence interrupted by semantic event; flushing"); - if !send_windows_raw_events(framer.flush_timeout(), &event_tx) { - return; - } - } - - if windows_event_is_control_key(&event) { - tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); - } - - let Some(event) = windows_crossterm_input_event(event) else { + let Some(event) = event else { continue; }; if event_tx @@ -401,10 +381,50 @@ fn windows_crossterm_reader_loop( } if framer.has_pending_input() { - let _ = send_windows_raw_events(framer.flush_timeout(), &event_tx); + let _ = send_windows_raw_events(framer.flush_interrupted(), &event_tx); } } +#[cfg(any(windows, test))] +fn frame_windows_crossterm_event( + framer: &mut crate::raw_input::RawInputFramer, + event: crossterm::event::Event, +) -> ( + Vec, + Option, +) { + let raw_sequence_pending = framer.has_pending_input(); + if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { + tracing::debug!( + bytes = ?bytes, + pending_before = raw_sequence_pending, + "windows input routed through raw framer" + ); + return (framer.push(&bytes), None); + } + + if windows_event_is_control_key(&event) { + tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); + } + let Some(event) = windows_crossterm_input_event(event) else { + // Preserve the existing flush for unrelated pending input, but do not + // cancel mouse recovery for an event that will not be forwarded. + return ( + if raw_sequence_pending { + framer.flush_timeout() + } else { + Vec::new() + }, + None, + ); + }; + if raw_sequence_pending { + tracing::debug!("windows input raw sequence interrupted by semantic event; flushing"); + } + // Even a dormant mouse prefix must stop claiming bytes after semantic input. + (framer.flush_interrupted(), Some(event)) +} + #[cfg(any(windows, test))] fn windows_crossterm_input_event( event: crossterm::event::Event, @@ -429,7 +449,7 @@ fn windows_crossterm_input_event( } } -#[cfg(windows)] +#[cfg(any(windows, test))] fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool { use crossterm::event::{Event, KeyModifiers}; @@ -773,6 +793,60 @@ mod windows_tests { ); } + #[test] + fn windows_crossterm_semantic_input_cancels_dormant_and_buffered_mouse_recovery() { + for buffered in [false, true] { + let mut framer = crate::raw_input::RawInputFramer::for_host_input(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + if buffered { + assert!(framer.push(b"5").is_empty()); + } + // Printable input bypasses the raw route when no bytes are pending; + // an arrow bypasses it even when a continuation is buffered. + let key = if buffered { + KeyCode::Up + } else { + KeyCode::Char('x') + }; + let event = Event::Key(KeyEvent::new(key, KeyModifiers::empty())); + let (raw, semantic) = frame_windows_crossterm_event(&mut framer, event.clone()); + let raw_bytes: Vec<_> = raw + .into_iter() + .filter_map(|event| { + let crate::raw_input::RawInputEvent::Key(key) = event else { + return None; + }; + key.vt_bytes().map(ToOwned::to_owned) + }) + .collect(); + assert_eq!( + raw_bytes.concat(), + if buffered { b"5".as_slice() } else { b"" } + ); + assert_eq!(semantic, windows_crossterm_input_event(event)); + assert_eq!(framer.push(b"5;28;31M").len(), 8); + } + } + + #[test] + fn windows_crossterm_ignored_event_preserves_mouse_recovery() { + let mut framer = crate::raw_input::RawInputFramer::for_host_input(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"5").is_empty()); + let (raw, semantic) = frame_windows_crossterm_event(&mut framer, Event::Resize(80, 24)); + assert!(raw.is_empty()); + assert!(semantic.is_none()); + let events = framer.push(b";28;31Mx"); + assert!( + matches!(events.as_slice(), [crate::raw_input::RawInputEvent::Key(key)] + if key.code == KeyCode::Char('x')) + ); + assert!(!framer.has_pending_input()); + } + #[test] fn windows_crossterm_printable_press_keeps_key_semantics_and_text() { let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty())); diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 679d5b31..0a00c146 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -364,7 +364,7 @@ impl WindowsInputPump { } if let Some(escape) = item.physical_escape_press() { if !self.framer.has_pending_bracketed_paste() { - let raw_events = self.framer.flush_timeout(); + let raw_events = self.framer.flush_interrupted(); events.extend(self.process_raw_events(raw_events)); self.pending_physical_escape = Some((escape, false)); return events; @@ -377,7 +377,7 @@ impl WindowsInputPump { self.process_raw_events(raw_events) } PlatformInputItem::Semantic(event) => { - let raw_events = self.framer.flush_timeout(); + let raw_events = self.framer.flush_interrupted(); let mut events = self.process_raw_events(raw_events); events.push(event); events @@ -436,7 +436,11 @@ impl WindowsInputPump { let raw_events = self.framer.push(&win32_paste_bytes); self.process_raw_events(raw_events) } else { - let raw_events = self.framer.flush_timeout(); + let raw_events = if events.is_empty() { + self.framer.flush_timeout() + } else { + self.framer.flush_interrupted() + }; let mut output = self.process_raw_events(raw_events); output.extend(events); output @@ -3449,6 +3453,91 @@ mod tests { ); } + #[test] + fn vti_mouse_recovery_survives_idle_and_split_continuations() { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + assert!(translator.idle().is_empty()); + for record in "5;28;31M".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + assert!(translator.idle().is_empty()); + } + assert_eq!( + translator.translate(key_char('x')), + translate_with_provenance([key_char('x')]) + ); + } + + #[test] + fn vti_ignored_modifier_record_preserves_mouse_recovery() { + for buffered in [false, true] { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + if buffered { + assert!(translator.translate(key_char('5')).is_empty()); + } + // A modifier-only Win32 input record emits no semantic input. + for record in "\x1b[17;0;0;1;8;3_".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + let tail = if buffered { ";28;31M" } else { "5;28;31M" }; + for record in tail.chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert_eq!( + translator.translate(key_char('x')), + translate_with_provenance([key_char('x')]) + ); + } + } + + #[test] + fn vti_semantic_input_cancels_mouse_recovery_and_preserves_order() { + // These hit Semantic, PasteAwareKey, and physical-Escape forwarding. + for interruption in [ + WindowsInputRecord::Focus(true), + key_vk_with_unicode(0x41, 'a', 0), + key_vk_with_scan_unicode(0x1b, 1, '\x1b', 0), + ] { + for buffered in [false, true] { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + if buffered { + assert!(translator.translate(key_char('5')).is_empty()); + } + let mut events = translator.translate(interruption); + events.extend(translator.idle()); + let mut expected = if buffered { + translate_with_provenance([key_char('5')]) + } else { + Vec::new() + }; + let mut baseline = WindowsInputTranslator::default(); + expected.extend(baseline.translate(interruption)); + expected.extend(baseline.idle()); + assert_eq!(events, expected); + + let mut later = Vec::new(); + for record in "5;28;31M".chars().map(key_char) { + later.extend(translator.translate(record)); + } + assert_eq!( + later, + translate_with_provenance("5;28;31M".chars().map(key_char)) + ); + } + } + } + #[test] fn vti_lone_escape_flushes_only_after_idle() { let mut translator = WindowsInputTranslator::default(); diff --git a/src/raw_input.rs b/src/raw_input.rs index e13d9133..266b892e 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -88,7 +88,7 @@ impl RawInputFramer { Self::events_from_chunks(self.byte_framer.push(data)) } - #[cfg(any(windows, all(test, not(target_os = "macos"))))] + #[cfg(any(windows, test))] pub(crate) fn has_pending_input(&self) -> bool { self.byte_framer.has_pending_input() } @@ -107,6 +107,15 @@ impl RawInputFramer { Self::events_from_chunks(self.byte_framer.flush_timeout()) } + /// Semantic input ends mouse recovery, unlike another idle interval. + #[cfg(any(windows, test))] + pub(crate) fn flush_interrupted(&mut self) -> Vec { + let mut chunks = self.byte_framer.flush_timeout(); + self.byte_framer.timed_out_mouse_prefix = None; + chunks.extend(self.byte_framer.drain_available_chunks()); + Self::events_from_chunks(chunks) + } + fn events_from_chunks(chunks: Vec>) -> Vec { chunks .into_iter() @@ -131,6 +140,8 @@ pub(crate) struct RawInputByteFramer { buffer: Vec, discard_until: Option, discarded_tail_bytes: usize, + // Keep the discarded prefix separate from continuation bytes awaiting validation. + timed_out_mouse_prefix: Option>, lone_escape_recently_flushed: bool, host_color_replies_awaited: u16, host_cell_size_replies_awaited: u16, @@ -227,17 +238,16 @@ impl RawInputByteFramer { pub(crate) fn flush_timeout(&mut self) -> Vec> { let mut chunks = self.drain_available_chunks(); + // Idle is not evidence that a mouse report has ended. The continuation + // stays bounded and is released if it cannot complete a valid report. + if self.timed_out_mouse_prefix.is_some() { + return chunks; + } + if let Some(family) = self.discard_until { if family == ControlStringFamily::HostReplyCsi { return chunks; } - if family == ControlStringFamily::OrphanedSgrMouseTail { - self.buffer.clear(); - self.discard_until = None; - self.discarded_tail_bytes = 0; - return chunks; - } - let keep_split_st = self.buffer.last() == Some(&ESC); let keep_discarding = plausible_control_string_tail(family, &self.buffer); self.discarded_tail_bytes = self.discarded_tail_bytes.saturating_add(self.buffer.len()); @@ -262,11 +272,9 @@ impl RawInputByteFramer { len = self.buffer.len(), "discarding incomplete orphaned SGR mouse tail after input timeout" ); - discard_or_buffer_orphaned_sgr_mouse_tail( - &mut self.buffer, - &mut self.discard_until, - &mut self.discarded_tail_bytes, - ); + let mut prefix = vec![ESC]; + prefix.append(&mut self.buffer); + self.retain_timed_out_mouse_prefix(prefix); self.lone_escape_recently_flushed = false; return chunks; } @@ -276,10 +284,8 @@ impl RawInputByteFramer { bytes = ?self.buffer, "discarding incomplete SGR mouse sequence after input timeout" ); - self.discarded_tail_bytes = self.buffer.len(); - self.discard_until = (self.discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES) - .then_some(ControlStringFamily::OrphanedSgrMouseTail); - self.buffer.clear(); + let prefix = std::mem::take(&mut self.buffer); + self.retain_timed_out_mouse_prefix(prefix); return chunks; } @@ -407,10 +413,27 @@ impl RawInputByteFramer { chunks } + fn retain_timed_out_mouse_prefix(&mut self, prefix: Vec) { + self.timed_out_mouse_prefix = (prefix.len() < MAX_DISCARDED_CONTROL_TAIL_BYTES + && plausible_sgr_mouse_prefix(&prefix)) + .then_some(prefix); + } + fn drain_available_chunks(&mut self) -> Vec> { let mut chunks = Vec::new(); loop { + if let Some(prefix) = &self.timed_out_mouse_prefix { + match classify_sgr_mouse_continuation(prefix, &self.buffer) { + SgrMouseContinuation::Incomplete => break, + SgrMouseContinuation::Complete(len) => { + self.buffer.drain(..len); + } + SgrMouseContinuation::Invalid => {} + } + self.timed_out_mouse_prefix = None; + } + if self.lone_escape_recently_flushed { if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) { break; @@ -432,18 +455,6 @@ impl RawInputByteFramer { } break; } - if family == ControlStringFamily::OrphanedSgrMouseTail { - if discard_orphaned_sgr_mouse_tail( - &mut self.buffer, - &mut self.discarded_tail_bytes, - ) { - self.discard_until = None; - self.discarded_tail_bytes = 0; - continue; - } - break; - } - let Some(terminator_len) = control_string_terminator_for_family(&self.buffer, family) else { @@ -518,9 +529,6 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> }), ControlStringFamily::StTerminated => buffer.last() == Some(&ESC), ControlStringFamily::HostReplyCsi => false, - ControlStringFamily::OrphanedSgrMouseTail => buffer - .iter() - .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')), } } @@ -633,7 +641,6 @@ enum ControlStringFamily { Osc, StTerminated, HostReplyCsi, - OrphanedSgrMouseTail, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -834,8 +841,10 @@ fn starts_with_incomplete_orphaned_sgr_mouse_tail(buffer: &[u8]) -> bool { } fn discard_complete_orphaned_sgr_mouse_tail(buffer: &mut Vec) -> bool { - let Some(terminator_len) = - control_string_terminator_for_family(buffer, ControlStringFamily::OrphanedSgrMouseTail) + let Some(terminator_len) = buffer + .iter() + .position(|byte| matches!(*byte, b'M' | b'm')) + .map(|idx| idx + 1) else { return false; }; @@ -855,19 +864,6 @@ fn discard_complete_orphaned_sgr_mouse_tail(buffer: &mut Vec) -> bool { true } -fn discard_or_buffer_orphaned_sgr_mouse_tail( - buffer: &mut Vec, - discard_until: &mut Option, - discarded_tail_bytes: &mut usize, -) { - if !discard_complete_orphaned_sgr_mouse_tail(buffer) { - *discarded_tail_bytes = buffer.len(); - *discard_until = (*discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES) - .then_some(ControlStringFamily::OrphanedSgrMouseTail); - buffer.clear(); - } -} - fn discard_host_reply_csi_tail(buffer: &mut Vec, discarded_tail_bytes: &mut usize) -> bool { let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes); let inspected = buffer.len().min(remaining); @@ -891,32 +887,76 @@ fn discard_host_reply_csi_tail(buffer: &mut Vec, discarded_tail_bytes: &mut *discarded_tail_bytes >= MAX_DISCARDED_CONTROL_TAIL_BYTES } -fn discard_orphaned_sgr_mouse_tail(buffer: &mut Vec, discarded_tail_bytes: &mut usize) -> bool { - let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes); - let inspected = buffer.len().min(remaining); +enum SgrMouseContinuation { + Incomplete, + Complete(usize), + Invalid, +} - for index in 0..inspected { - match buffer[index] { - b'0'..=b'9' | b';' => {} - b'M' | b'm' => { - buffer.drain(..=index); - return true; - } - _ => { - buffer.drain(..index); - return true; - } +fn classify_sgr_mouse_continuation(prefix: &[u8], tail: &[u8]) -> SgrMouseContinuation { + let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(prefix.len()); + let tail = &tail[..tail.len().min(remaining)]; + let final_index = tail + .iter() + .position(|byte| !byte.is_ascii_digit() && *byte != b';'); + let payload_len = final_index.unwrap_or(tail.len()); + let mut report = prefix.to_vec(); + report.extend_from_slice(&tail[..payload_len]); + if !plausible_sgr_mouse_prefix(&report) { + return SgrMouseContinuation::Invalid; + } + if let Some(index) = final_index { + report.push(tail[index]); + let valid = std::str::from_utf8(&report) + .ok() + .and_then(parse_sgr_mouse) + .is_some(); + return if valid { + SgrMouseContinuation::Complete(index + 1) + } else { + SgrMouseContinuation::Invalid + }; + } + if report.len() >= MAX_DISCARDED_CONTROL_TAIL_BYTES { + SgrMouseContinuation::Invalid + } else { + SgrMouseContinuation::Incomplete + } +} + +// Reject impossible continuations early, without changing the general mouse +// parser. A partial last field (including zero) can still become valid. +fn plausible_sgr_mouse_prefix(report: &[u8]) -> bool { + let Some(body) = report.strip_prefix(b"\x1b[<") else { + return false; + }; + let mut fields = body.split(|byte| *byte == b';').enumerate().peekable(); + while let Some((field, digits)) = fields.next() { + if field > 2 { + return false; + } + if digits.is_empty() { + return fields.peek().is_none(); + } + if !digits.iter().all(u8::is_ascii_digit) { + return false; + } + let Some(value) = std::str::from_utf8(digits) + .ok() + .and_then(|digits| digits.parse::().ok()) + else { + return false; + }; + if field == 0 && value > u16::from(u8::MAX) { + return false; + } + if fields.peek().is_some() + && ((field == 0 && parse_mouse_cb(value as u8).is_none()) || (field == 1 && value == 0)) + { + return false; } } - - *discarded_tail_bytes = discarded_tail_bytes.saturating_add(inspected); - if buffer.len() > inspected { - buffer.clear(); - return true; - } - - buffer.clear(); - false + true } fn osc_string_terminator(buffer: &[u8]) -> Option { @@ -946,10 +986,6 @@ fn control_string_terminator_for_family( ControlStringFamily::Osc => osc_string_terminator(buffer), ControlStringFamily::StTerminated => st_string_terminator(buffer), ControlStringFamily::HostReplyCsi => None, - ControlStringFamily::OrphanedSgrMouseTail => buffer - .iter() - .position(|byte| matches!(*byte, b'M' | b'm')) - .map(|idx| idx + 1), } } @@ -1839,7 +1875,20 @@ mod tests { } #[test] - fn timed_out_sgr_mouse_discard_state_clears_at_quiescence() { + fn captured_sgr_mouse_tail_after_second_idle_flush_is_discarded() { + let mut framer = RawInputByteFramer::for_host_input(); + + // Issue #3911, 2026-09-13 07:02:14 UTC: this prefix timed out, + // then its tail arrived 33 ms later. The Unix reader flushes again + // after 10 ms of continued idle following the first discard. + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!(framer.push(b"5;28;31M"), Vec::>::new()); + } + + #[test] + fn timed_out_sgr_mouse_invalid_completion_is_preserved_after_idle() { let mut framer = RawInputByteFramer::default(); assert!(framer.push(b"\x1b[<3").is_empty()); @@ -1848,6 +1897,100 @@ mod tests { assert_eq!(framer.push(b"M"), vec![b"M".to_vec()]); } + #[test] + fn timed_out_sgr_mouse_completion_survives_read_splits_and_idle() { + let tail = b"5;28;31M"; + for split in 0..=tail.len() { + let mut framer = RawInputByteFramer::for_host_input(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(&tail[..split]).is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + let mut rest = tail[split..].to_vec(); + rest.extend_from_slice(b"x\x1b[A"); + assert_eq!(framer.push(&rest), vec![b"x".to_vec(), b"\x1b[A".to_vec()]); + assert!(framer.timed_out_mouse_prefix.is_none()); + assert!(!framer.has_pending_input()); + } + } + + #[test] + fn timed_out_sgr_mouse_invalid_syntax_releases_continuation() { + for tail in [ + b"5;0;31M".as_slice(), // zero coordinate + b"5;;31M", // empty field + b"5;28;31;1M", // extra field (the general parser is permissive) + b"999;28;31M", // button overflow + b"5;65536;31M", // coordinate overflow + ] { + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!(framer.push(tail).concat(), tail); + assert!(framer.timed_out_mouse_prefix.is_none()); + } + } + + #[test] + fn timed_out_sgr_mouse_interruption_preserves_text_and_new_events() { + for suffix in [ + b"x".as_slice(), + b"\x1b[A", // new key sequence + b"\x1b[200~paste\x1b[201~", // bracketed paste + "\u{4f60}".as_bytes(), // UTF-8 split across reads + ] { + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"5;28;").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let mut chunks = Vec::new(); + for byte in suffix { + chunks.extend(framer.push(&[*byte])); + } + let mut expected = b"5;28;".to_vec(); + expected.extend_from_slice(suffix); + assert_eq!(chunks.concat(), expected); + assert!(framer.timed_out_mouse_prefix.is_none()); + assert_eq!(framer.push(b"123M").concat(), b"123M"); + } + } + + #[test] + fn timed_out_sgr_mouse_budget_includes_prefix_and_preserves_overflow() { + let prefix = b"\x1b[<35;1;"; + let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES - prefix.len(); + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + let mut valid_tail = vec![b'0'; remaining - 2]; + valid_tail.extend_from_slice(b"1M"); + assert!(framer.push(&valid_tail).is_empty()); // complete exactly at limit + assert!(framer.timed_out_mouse_prefix.is_none()); + + for length in [remaining, remaining + 1024] { + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + let tail = vec![b'0'; length]; + assert_eq!(framer.push(&tail).concat(), tail); + assert!(framer.timed_out_mouse_prefix.is_none()); + assert_eq!(framer.push(b"1Mtext").concat(), b"1Mtext"); + } + + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + let mut tail = vec![b'0'; remaining - 1]; + assert!(framer.push(&tail).is_empty()); + assert!(framer.flush_timeout().is_empty()); + tail.extend_from_slice(b"01M"); + assert_eq!(framer.push(b"01M").concat(), tail); + assert!(framer.timed_out_mouse_prefix.is_none()); + } + #[test] fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() { let mut framer = RawInputFramer::default(); @@ -1905,6 +2048,7 @@ mod tests { assert!(framer.push(b"[<65;4").is_empty()); assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); let events = framer.push(b"3;26Mx"); assert_eq!(events.len(), 1); assert_raw_key(