mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-14 18:03:41 +08:00
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 <jonathan.liebig@gmail.com>
This commit is contained in:
+100
-26
@@ -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<crate::raw_input::RawInputEvent>,
|
||||
Option<crate::protocol::ClientInputEvent>,
|
||||
) {
|
||||
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()));
|
||||
|
||||
@@ -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();
|
||||
|
||||
+220
-76
@@ -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<RawInputEvent> {
|
||||
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<u8>>) -> Vec<RawInputEvent> {
|
||||
chunks
|
||||
.into_iter()
|
||||
@@ -131,6 +140,8 @@ pub(crate) struct RawInputByteFramer {
|
||||
buffer: Vec<u8>,
|
||||
discard_until: Option<ControlStringFamily>,
|
||||
discarded_tail_bytes: usize,
|
||||
// Keep the discarded prefix separate from continuation bytes awaiting validation.
|
||||
timed_out_mouse_prefix: Option<Vec<u8>>,
|
||||
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<Vec<u8>> {
|
||||
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<u8>) {
|
||||
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<Vec<u8>> {
|
||||
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<u8>) -> 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<u8>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn discard_or_buffer_orphaned_sgr_mouse_tail(
|
||||
buffer: &mut Vec<u8>,
|
||||
discard_until: &mut Option<ControlStringFamily>,
|
||||
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<u8>, 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<u8>, discarded_tail_bytes: &mut
|
||||
*discarded_tail_bytes >= MAX_DISCARDED_CONTROL_TAIL_BYTES
|
||||
}
|
||||
|
||||
fn discard_orphaned_sgr_mouse_tail(buffer: &mut Vec<u8>, 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::<u16>().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<usize> {
|
||||
@@ -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::<Vec<u8>>::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(
|
||||
|
||||
Reference in New Issue
Block a user