aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-05-08 09:51:20 +0200
committerGitHub <noreply@github.com>2026-05-08 09:51:20 +0200
commite927b2b659296c19e421bc36d1b702f2c8d24fd3 (patch)
tree3b33e0ca9fb1b89c08bdfb5e12ef87b103449aee
parentfix(windows): bump windows-sys to 0.59 (#5139) (diff)
downloadsl0ppij-e927b2b659296c19e421bc36d1b702f2c8d24fd3.tar.gz
sl0ppij-e927b2b659296c19e421bc36d1b702f2c8d24fd3.tar.xz
sl0ppij-e927b2b659296c19e421bc36d1b702f2c8d24fd3.zip
fix: occasional stall when querying host terminal (introduced in 0.44.1) (#5149)
* fix(client): unwedge termwiz on unrecognized CSI sequences * rustfmt * add pr
-rw-r--r--CHANGELOG.md1
-rw-r--r--zellij-client/src/stdin_ansi_parser_tests.rs58
-rw-r--r--zellij-utils/src/vendored/termwiz/input.rs68
3 files changed, 127 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 058c29c7..7455a21d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/zellij-client/src/stdin_ansi_parser_tests.rs b/zellij-client/src/stdin_ansi_parser_tests.rs
index 0c3b442e..b34aa343 100644
--- a/zellij-client/src/stdin_ansi_parser_tests.rs
+++ b/zellij-client/src/stdin_ansi_parser_tests.rs
@@ -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
+ );
+ }
+}
diff --git a/zellij-utils/src/vendored/termwiz/input.rs b/zellij-utils/src/vendored/termwiz/input.rs
index c25f712b..ec84a77f 100644
--- a/zellij-utils/src/vendored/termwiz/input.rs
+++ b/zellij-utils/src/vendored/termwiz/input.rs
@@ -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) => {