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:
ZrjaK
2026-08-10 14:17:09 +02:00
committed by GitHub
co-authored by Aram Drevekenin
parent 5254e4fc1d
commit 1d1d5c1101
3 changed files with 162 additions and 1 deletions
+60 -1
View File
@@ -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() {
+51
View File
@@ -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