From e927b2b659296c19e421bc36d1b702f2c8d24fd3 Mon Sep 17 00:00:00 2001 From: Aram Drevekenin Date: Fri, 8 May 2026 09:51:20 +0200 Subject: [PATCH] fix: occasional stall when querying host terminal (introduced in 0.44.1) (#5149) * fix(client): unwedge termwiz on unrecognized CSI sequences * rustfmt * add pr --- CHANGELOG.md | 1 + zellij-client/src/stdin_ansi_parser_tests.rs | 58 +++++++++++++++++ zellij-utils/src/vendored/termwiz/input.rs | 68 ++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 058c29c7f..7455a21db 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 0c3b442e8..b34aa343d 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[;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[;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 c25f712b8..ec84a77f3 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[ * * ` 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[;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 { + 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[;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[;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) => {