fix: occasional stall when querying host terminal (introduced in 0.44.1) (#5149)

* fix(client): unwedge termwiz on unrecognized CSI sequences

* rustfmt

* add pr
This commit is contained in:
Aram Drevekenin
2026-05-08 09:51:20 +02:00
committed by GitHub
parent cc86e31b84
commit e927b2b659
3 changed files with 127 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased]
* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133)
* fix(windows): bump windows-sys to 0.59 to align manifest with code, fixing source builds via `cargo install`/`cargo binstall` (https://github.com/zellij-org/zellij/pull/5139)
* fix: occasional stall before prompt/program-run (https://github.com/zellij-org/zellij/pull/5149)
## [0.44.2] - 2026-05-05
* fix: idle CPU + disk i/o, CommandChanged plugin Event, GetSessionList plugin command (https://github.com/zellij-org/zellij/pull/5063)
@@ -817,3 +817,61 @@ fn host_theme_dsr_997_unknown_param_dropped() {
"unknown ?997;N value must not classify"
);
}
// =====================================================================
// Regression test: a Kitty keyboard-protocol event arriving on the
// client's stdin must not wedge the parser. Before the fix, such an
// event (`\x1b[<keycode>;<mods>u`) sat at the head of termwiz's
// internal buffer because parse_csi_report rejects the unwhitelisted
// final byte 'u' AND the keymap returned `Found::NeedData` (it's a
// possible prefix of registered key sequences). Every host reply that
// arrived behind it was silently swallowed, stalling host-color
// forwards until session timeout. This was confirmed via WEDGE-CANDIDATE
// log entries from three independent users on Kitty, Ghostty, and
// Konsole — all triggered by the same `\x1b[<digits>;<digits>u` shape.
//
// Captured triggers from real logs:
// `\x1b[57414;129u` (Kitty user, repro-logs/third-commit/zellij(4).log)
// `\x1b[27;129u` (Ghostty user, ...zellij(5).log)
// `\x1b[102;133u` (Konsole user, ...zellij_0.45_new-2.log)
// =====================================================================
#[test]
fn kitty_kbd_event_does_not_wedge_subsequent_forward_reply() {
let kbd_events: Vec<&[u8]> = vec![
b"\x1b[57414;129u", // captured from Kitty user
b"\x1b[27;129u", // captured from Ghostty user
b"\x1b[102;133u", // captured from Konsole user
b"\x1b[A", // legacy CSI key (not Kitty kbd) — sanity
];
for kbd in &kbd_events {
let pretty = String::from_utf8_lossy(kbd).replace('\x1b', "ESC");
let mut parser = StdinAnsiParser::new();
// Pre-wedge: keypress arrives before the forward goes out.
let _ = parser.feed(kbd);
// Forward dispatched — slot opens for token=42.
parser.open_forward(42);
// Host's reply arrives in two chunks (matches captured wire:
// OSC 11 reply 25B, then DA1 barrier 10B).
let _ = parser.feed(b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\");
let out = parser.feed(b"\x1b[?62;52;c");
let (token, bytes) = out.completed_forward.unwrap_or_else(|| {
panic!(
"BARRIER never closed slot for kbd-event prelude {:?} — \
termwiz wedge regression",
pretty
)
});
assert_eq!(token, 42, "wrong token closed for prelude {:?}", pretty);
assert!(
!bytes.is_empty(),
"OSC payload was lost behind the kbd event for prelude {:?}",
pretty
);
}
}
@@ -838,6 +838,38 @@ fn parse_sgr_mouse(buf: &[u8]) -> Option<(InputEvent, usize)> {
/// `ESC [` for two distinct cases — not currently disambiguated here:
/// - truly malformed / unsupported sequence, or
/// - incomplete input; caller handles incompleteness via `maybe_more`.
/// Return `Some(len)` if `buf` starts with a structurally complete CSI
/// sequence (`\x1b[ <params>* <intermediates>* <final>` per ECMA-48 §5.4),
/// regardless of whether the final byte is one we have a use for. Used by
/// `process_bytes` to advance past CSI sequences the keymap doesn't
/// recognise — most importantly Kitty keyboard-protocol events
/// `\x1b[<keycode>;<mods>u`. Without this, the keymap returns
/// `Found::NeedData` (it sees the bytes as a possible prefix of a longer
/// registered key) and the parser wedges holding bytes that will never
/// extend into anything.
///
/// Returns `None` if the buffer doesn't start with `\x1b[`, contains a
/// non-CSI byte, or hasn't yet received its final byte.
fn complete_csi_len(buf: &[u8]) -> Option<usize> {
if buf.get(0) != Some(&0x1b) || buf.get(1) != Some(&b'[') {
return None;
}
let mut i = 2;
let max_scan = buf.len().min(256);
while i < max_scan {
let b = buf[i];
match b {
// Parameters (digits, ;, :, ?, <, =, >) and intermediates (space..'/').
0x30..=0x3F | 0x20..=0x2F => i += 1,
// Any byte in the final-byte range terminates a CSI sequence.
0x40..=0x7E => return Some(i + 1),
// Anything else means this isn't a well-formed CSI.
_ => return None,
}
}
None
}
fn parse_csi_report(buf: &[u8]) -> Option<(InputEvent, usize)> {
if buf.get(0) != Some(&0x1b) || buf.get(1) != Some(&b'[') {
return None;
@@ -1701,6 +1733,42 @@ impl InputParser {
self.buf.advance(len);
},
(Found::Ambiguous(_, _), true) | (Found::NeedData, true) => {
// The keymap is signalling "this buffer
// could still grow into a registered key,
// give me more bytes." That verdict is
// wrong when the buffer already holds a
// structurally complete CSI sequence whose
// final byte isn't in the keymap — most
// importantly Kitty keyboard-protocol
// events `\x1b[<keycode>;<mods>u`, which
// never grow into anything the keymap
// knows. Returning here would wedge
// `self.buf` indefinitely, swallowing every
// host reply that arrives behind it (the
// OSC + DA1 bytes for a forwarded
// `OSC 11;?` query among them) and stalling
// host-color forwards until session exit.
//
// Both `Ambiguous(_, true)` and
// `NeedData(true)` reach this point in
// practice: for `\x1b[<digits>;<digits>u`
// the trie reports `Ambiguous(1, Escape)`
// (it has ESC alone as a match and ESC[…]
// as longer prefixes), so the fix must
// cover both verdicts.
//
// Skip past the unrecognised CSI without
// emitting an event; callers that need
// keyboard dispatch (kitty_parser, the
// separate `input_parser` instance fed the
// residue from `StdinAnsiParser`) see the
// same bytes via `strip_replies`, which
// already treats unwhitelisted-final CSIs
// as `Malformed` and pushes them through.
if let Some(len) = complete_csi_len(self.buf.as_slice()) {
self.buf.advance(len);
continue;
}
return;
},
(Found::None, _) | (Found::NeedData, false) => {