mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-12 02:27:24 +00:00
fix(input): realign raw-byte buffer with the keyboard parser to stop stale bytes eating keystrokes (#5446)
* fix(input): realign raw-byte buffer with the keyboard parser to stop stale bytes eating keystrokes After a paste (e.g. via a clipboard manager like maccy), zellij's current_buffer can hold up to a few stale bytes that termwiz's InputParser has already consumed into events. Those stale bytes are then attached to the raw payload of subsequent KeyEvent messages, desyncing each keystroke from the bytes that produced it: keys go missing, come out garbled, or drop into vi command mode. The root cause is that parse_with_consumed reports consumption relative to its internal self.buf, while stdin_handler drains a separately accumulated current_buffer. The two can drift apart whenever a read leaves a partial sequence that the parser holds under maybe_more. Reconcile the two after every parse: trim current_buffer to the parser's buffered length, so no byte the parser has already decoded is ever forwarded with a later key event. Add InputParser::buffered_len to the vendored termwiz for that purpose. * fix: remove parsed bytes from front rather than back * rustfmt --------- Co-authored-by: Aram Drevekenin <[email protected]>
This commit is contained in:
@@ -224,6 +224,7 @@ pub(crate) fn stdin_loop(
|
||||
break 'stdin;
|
||||
}
|
||||
}
|
||||
realign_current_buffer(&mut current_buffer, &input_parser);
|
||||
|
||||
schedule_finalization(
|
||||
&stdin_ansi_parser,
|
||||
@@ -330,6 +331,19 @@ fn drain_partial_to_keyboard(
|
||||
.send(InputInstruction::KeyEvent(input_event, raw_bytes))
|
||||
.unwrap();
|
||||
}
|
||||
realign_current_buffer(current_buffer, input_parser);
|
||||
}
|
||||
|
||||
/// Trim `current_buffer` to the parser's own buffered length so it can
|
||||
/// never drift from the parser's internal state: a trailing incomplete
|
||||
/// sequence is held by the parser (and mirrored here) until the next
|
||||
/// read completes it, while bytes already decoded into events are dropped.
|
||||
fn realign_current_buffer(current_buffer: &mut Vec<u8>, input_parser: &InputParser) {
|
||||
let buffered = input_parser.buffered_len();
|
||||
let excess = current_buffer.len().saturating_sub(buffered);
|
||||
if excess > 0 {
|
||||
current_buffer.drain(..excess);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the fire-and-forget host-query batch sent at client startup.
|
||||
@@ -360,7 +374,52 @@ pub(crate) const PIXEL_SIZE_QUERY: &str = "\u{1b}[14t\u{1b}[16t";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_startup_query_string, PIXEL_SIZE_QUERY};
|
||||
use super::{
|
||||
build_startup_query_string, realign_current_buffer, InputParser, PIXEL_SIZE_QUERY,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn realign_after_lone_paste_start_empties_the_buffer() {
|
||||
let mut parser = InputParser::new();
|
||||
parser.parse_with_consumed(b"\x1b[200~", |_, _| {}, true);
|
||||
let mut current_buffer = b"\x1b[200~".to_vec();
|
||||
realign_current_buffer(&mut current_buffer, &parser);
|
||||
assert!(
|
||||
current_buffer.is_empty(),
|
||||
"the silently consumed paste-start bytes must not linger: {:?}",
|
||||
current_buffer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realign_drops_stale_bytes_from_the_front_and_keeps_the_pending_tail() {
|
||||
let mut parser = InputParser::new();
|
||||
parser.parse_with_consumed(b"\x1b[200~hel", |_, _| {}, true);
|
||||
let mut current_buffer = b"\x1b[200~hel".to_vec();
|
||||
realign_current_buffer(&mut current_buffer, &parser);
|
||||
assert_eq!(
|
||||
current_buffer, b"hel",
|
||||
"the retained bytes must be the parser's pending tail, not the stale front"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realign_is_a_no_op_when_nothing_was_consumed() {
|
||||
let mut parser = InputParser::new();
|
||||
parser.parse_with_consumed(b"\x1b[1;2", |_, _| {}, true);
|
||||
let mut current_buffer = b"\x1b[1;2".to_vec();
|
||||
realign_current_buffer(&mut current_buffer, &parser);
|
||||
assert_eq!(current_buffer, b"\x1b[1;2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realign_tolerates_a_shorter_mirror_buffer() {
|
||||
let mut parser = InputParser::new();
|
||||
parser.parse_with_consumed(b"\x1b[1;2", |_, _| {}, true);
|
||||
let mut current_buffer = b";2".to_vec();
|
||||
realign_current_buffer(&mut current_buffer, &parser);
|
||||
assert_eq!(current_buffer, b";2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_size_query_probes_text_area_and_character_cell() {
|
||||
|
||||
@@ -216,6 +216,57 @@ fn bracketed_paste() {
|
||||
zellij.quit();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keystroke_after_split_bracketed_paste_arrives_intact() {
|
||||
let mut zellij = start_zellij();
|
||||
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
|
||||
|
||||
zellij.send_stdin(&keys::BRACKETED_PASTE_START);
|
||||
zellij.send_stdin(b"hello\x1b[201~");
|
||||
zellij.send_stdin(&keys::key('x'));
|
||||
|
||||
terminal.wait_for_stdin(
|
||||
"keystroke following a split bracketed paste reached the pane unshifted",
|
||||
|stdin_bytes| stdin_bytes.windows(6).any(|window| window == b"hellox"),
|
||||
);
|
||||
zellij.quit();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keystrokes_sharing_a_read_with_mouse_motion_reach_the_pane_clean() {
|
||||
let mut zellij = start_zellij();
|
||||
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
|
||||
|
||||
zellij.send_stdin(b"a\x1b[<35;30;10M\x1b[<35;31;10M\x1b[<35;32;10Mb");
|
||||
zellij.send_stdin(&keys::key('c'));
|
||||
|
||||
let stdin_bytes = terminal
|
||||
.wait_for_stdin("all three keystrokes reached the pane", |stdin_bytes| {
|
||||
stdin_bytes.windows(3).any(|window| window == b"abc")
|
||||
});
|
||||
assert!(
|
||||
!stdin_bytes.windows(3).any(|window| window == b"\x1b[<"),
|
||||
"mouse report bytes must not be forwarded to the pane: {:?}",
|
||||
stdin_bytes
|
||||
);
|
||||
zellij.quit();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_key_split_across_reads_after_parked_esc_arrives_intact() {
|
||||
let mut zellij = start_zellij();
|
||||
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
|
||||
|
||||
zellij.send_stdin(b"\x1b\xc3");
|
||||
zellij.send_stdin(b"\xa9");
|
||||
|
||||
terminal.wait_for_stdin(
|
||||
"the utf-8 bytes of the alt-modified key reached the pane",
|
||||
|stdin_bytes| stdin_bytes.windows(2).any(|window| window == [0xc3, 0xa9]),
|
||||
);
|
||||
zellij.quit();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_inside_a_pane() {
|
||||
let mut zellij = start_zellij();
|
||||
|
||||
@@ -1891,6 +1891,14 @@ impl InputParser {
|
||||
);
|
||||
}
|
||||
|
||||
/// Number of bytes still held unprocessed in the parser's internal
|
||||
/// buffer. A caller that mirrors this ring separately (e.g. to forward
|
||||
/// raw bytes alongside decoded events) can reconcile its own buffer to
|
||||
/// exactly the same length so the two never drift apart.
|
||||
pub fn buffered_len(&self) -> usize {
|
||||
self.buf.len()
|
||||
}
|
||||
|
||||
pub fn parse_as_vec(&mut self, bytes: &[u8], maybe_more: bool) -> Vec<InputEvent> {
|
||||
let mut result = Vec::new();
|
||||
self.parse(bytes, |event| result.push(event), maybe_more);
|
||||
@@ -2130,6 +2138,49 @@ mod test {
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_start_alone_is_consumed_silently_and_not_buffered() {
|
||||
let mut p = InputParser::new();
|
||||
let mut events: Vec<(InputEvent, usize)> = Vec::new();
|
||||
p.parse_with_consumed(b"\x1b[200~", |ev, n| events.push((ev, n)), MAYBE_MORE);
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"a lone paste-start marker must produce no events, got {:?}",
|
||||
events
|
||||
);
|
||||
assert_eq!(
|
||||
p.buffered_len(),
|
||||
0,
|
||||
"the paste-start bytes are consumed out of the parser buffer without any event reporting them"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_start_with_partial_payload_buffers_only_the_payload() {
|
||||
let mut p = InputParser::new();
|
||||
let mut events: Vec<(InputEvent, usize)> = Vec::new();
|
||||
p.parse_with_consumed(b"\x1b[200~hel", |ev, n| events.push((ev, n)), MAYBE_MORE);
|
||||
assert!(events.is_empty(), "got {:?}", events);
|
||||
assert_eq!(
|
||||
p.buffered_len(),
|
||||
3,
|
||||
"only the pending paste payload remains buffered; the 6 marker bytes were consumed silently"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parked_esc_before_partial_utf8_is_consumed_out_of_the_buffer() {
|
||||
let mut p = InputParser::new();
|
||||
let mut events: Vec<(InputEvent, usize)> = Vec::new();
|
||||
p.parse_with_consumed(b"\x1b\xc3", |ev, n| events.push((ev, n)), MAYBE_MORE);
|
||||
assert!(events.is_empty(), "got {:?}", events);
|
||||
assert_eq!(
|
||||
p.buffered_len(),
|
||||
1,
|
||||
"the parked ESC is held in parser state, not in the buffer; only the partial UTF-8 byte remains"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_then_carriage_return_are_two_enter_events_with_their_own_bytes() {
|
||||
// In the legacy encoding a terminal sends `\r` for the Enter key and
|
||||
|
||||
Reference in New Issue
Block a user