aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-06-30 16:48:37 +0200
committerGitHub <noreply@github.com>2026-06-30 16:48:37 +0200
commit6a6376cebb0a3a85b39bbf3242dcd2ae58d82cc5 (patch)
treecbb2c7f0e2deb5d7e941b9c8ef10d36c1bd2e2a4
parentFish completion delete session (#5317) (diff)
downloadsl0ppij-6a6376cebb0a3a85b39bbf3242dcd2ae58d82cc5.tar.gz
sl0ppij-6a6376cebb0a3a85b39bbf3242dcd2ae58d82cc5.tar.xz
sl0ppij-6a6376cebb0a3a85b39bbf3242dcd2ae58d82cc5.zip
fix: occasional garbage on attaching through slow connections (#5320)
* fix: occasional garbage on attaching through slow connections * rustfmt * add PR
-rw-r--r--CHANGELOG.md1
-rw-r--r--zellij-client/src/stdin_ansi_parser.rs118
-rw-r--r--zellij-client/src/stdin_ansi_parser_tests.rs358
-rw-r--r--zellij-client/src/stdin_handler.rs138
-rw-r--r--zellij-integration-tests/tests/startup_host_query.rs102
5 files changed, 661 insertions, 56 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae44d494..5c96dcc3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* fix: live reload on theme dir changes (https://github.com/zellij-org/zellij/pull/5135)
* feat: new title-frames UI (https://github.com/zellij-org/zellij/pull/5318)
* chore: upgrade wasmi to 1.1.0 (https://github.com/zellij-org/zellij/pull/5319)
+* fix: fragmented STDIN causing "garbage" input on slow SSH connections (https://github.com/zellij-org/zellij/pull/5320)
## [0.44.3] - 2026-05-13
* 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)
diff --git a/zellij-client/src/stdin_ansi_parser.rs b/zellij-client/src/stdin_ansi_parser.rs
index d6800010..d0fb4c32 100644
--- a/zellij-client/src/stdin_ansi_parser.rs
+++ b/zellij-client/src/stdin_ansi_parser.rs
@@ -204,6 +204,37 @@ pub struct ParseOutput {
/// bounded.
const PARTIAL_BUFFER_CAP_BYTES: usize = 100 * 1024 * 1024;
+const PASTE_START_MARKER: &[u8] = b"\x1b[200~";
+const PASTE_END_MARKER: &[u8] = b"\x1b[201~";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PendingPartial {
+ None,
+ LoneEsc,
+ ReplyInProgress,
+}
+
+#[derive(Debug, Clone, Copy)]
+enum MarkerMatch {
+ Complete,
+ Prefix,
+ No,
+}
+
+fn marker_match(buf: &[u8], marker: &[u8]) -> MarkerMatch {
+ if buf.len() >= marker.len() {
+ if &buf[..marker.len()] == marker {
+ MarkerMatch::Complete
+ } else {
+ MarkerMatch::No
+ }
+ } else if marker.starts_with(buf) {
+ MarkerMatch::Prefix
+ } else {
+ MarkerMatch::No
+ }
+}
+
/// Outcome of a single OSC/CSI walk over a byte buffer. Distinguishing
/// "needs more bytes" from "malformed" is what lets the residue
/// scrubber buffer partial sequences across `feed()` calls instead of
@@ -232,6 +263,8 @@ pub struct StdinAnsiParser {
partial_osc: Vec<u8>,
/// Same for CSI device-control reports.
partial_csi: Vec<u8>,
+ partial_paste: Vec<u8>,
+ in_bracketed_paste: bool,
}
impl std::fmt::Debug for StdinAnsiParser {
@@ -251,6 +284,8 @@ impl StdinAnsiParser {
active_forward: None,
partial_osc: Vec::new(),
partial_csi: Vec::new(),
+ partial_paste: Vec::new(),
+ in_bracketed_paste: false,
}
}
@@ -394,20 +429,43 @@ impl StdinAnsiParser {
// rather than leaking into residue.
residue.extend(self.strip_replies(bytes));
out.residue = residue;
- out.has_partial_state = !self.partial_osc.is_empty() || !self.partial_csi.is_empty();
+ out.has_partial_state = !self.partial_osc.is_empty()
+ || !self.partial_csi.is_empty()
+ || !self.partial_paste.is_empty();
out
}
- /// Drain any speculatively-buffered partial OSC/CSI bytes back into
- /// keyboard residue. Called from the stdin handler's idle-timeout
- /// path so a lone trailing ESC (or any unterminated host-reply
- /// prefix that never received a follow-up byte) reaches the
- /// keyboard parser instead of being stuck forever waiting for a
- /// disambiguating byte that is never coming.
- pub fn finalize(&mut self) -> Vec<u8> {
- let mut out = Vec::with_capacity(self.partial_osc.len() + self.partial_csi.len());
+ pub fn pending_partial(&self) -> PendingPartial {
+ if !self.partial_paste.is_empty() {
+ PendingPartial::ReplyInProgress
+ } else if self.partial_csi.is_empty() && self.partial_osc.is_empty() {
+ PendingPartial::None
+ } else if self.partial_csi.is_empty() && self.partial_osc == [0x1b] {
+ PendingPartial::LoneEsc
+ } else {
+ PendingPartial::ReplyInProgress
+ }
+ }
+
+ pub fn finalize_lone_esc(&mut self) -> Vec<u8> {
+ if self.partial_csi.is_empty()
+ && self.partial_paste.is_empty()
+ && self.partial_osc == [0x1b]
+ {
+ std::mem::take(&mut self.partial_osc)
+ } else {
+ Vec::new()
+ }
+ }
+
+ pub fn finalize_force(&mut self) -> Vec<u8> {
+ self.in_bracketed_paste = false;
+ let mut out = Vec::with_capacity(
+ self.partial_osc.len() + self.partial_csi.len() + self.partial_paste.len(),
+ );
out.append(&mut self.partial_osc);
out.append(&mut self.partial_csi);
+ out.append(&mut self.partial_paste);
out
}
@@ -426,16 +484,54 @@ impl StdinAnsiParser {
// partial_csi) is non-empty at any time — the previous walk
// either completed all sequences or stopped at exactly one
// unterminated tail.
- let mut working: Vec<u8> =
- Vec::with_capacity(self.partial_osc.len() + self.partial_csi.len() + bytes.len());
+ let mut working: Vec<u8> = Vec::with_capacity(
+ self.partial_osc.len()
+ + self.partial_csi.len()
+ + self.partial_paste.len()
+ + bytes.len(),
+ );
working.append(&mut self.partial_osc);
working.append(&mut self.partial_csi);
+ working.append(&mut self.partial_paste);
working.extend_from_slice(bytes);
let mut out = Vec::with_capacity(working.len());
let mut i = 0;
while i < working.len() {
let rest = &working[i..];
+ if self.in_bracketed_paste {
+ if rest[0] == 0x1b {
+ match marker_match(rest, PASTE_END_MARKER) {
+ MarkerMatch::Complete => {
+ out.extend_from_slice(PASTE_END_MARKER);
+ self.in_bracketed_paste = false;
+ i += PASTE_END_MARKER.len();
+ continue;
+ },
+ MarkerMatch::Prefix => {
+ let tail = rest.to_vec();
+ if tail.len() > PARTIAL_BUFFER_CAP_BYTES {
+ out.extend_from_slice(&tail);
+ } else {
+ self.partial_paste = tail;
+ }
+ return out;
+ },
+ MarkerMatch::No => {},
+ }
+ }
+ out.push(working[i]);
+ i += 1;
+ continue;
+ }
+ if rest.len() >= 2 && rest[0] == 0x1b && rest[1] == b'[' {
+ if let MarkerMatch::Complete = marker_match(rest, PASTE_START_MARKER) {
+ out.extend_from_slice(PASTE_START_MARKER);
+ self.in_bracketed_paste = true;
+ i += PASTE_START_MARKER.len();
+ continue;
+ }
+ }
// OSC: ESC ] ... (BEL | ESC \)
if rest.len() >= 2 && rest[0] == 0x1b && rest[1] == b']' {
match osc_status(rest) {
diff --git a/zellij-client/src/stdin_ansi_parser_tests.rs b/zellij-client/src/stdin_ansi_parser_tests.rs
index b34aa343..19fd5698 100644
--- a/zellij-client/src/stdin_ansi_parser_tests.rs
+++ b/zellij-client/src/stdin_ansi_parser_tests.rs
@@ -1,6 +1,6 @@
//! Unit tests for the continuous host-reply parser.
-use super::{schedule_forward_timeout, HostReply, StdinAnsiParser};
+use super::{schedule_forward_timeout, HostReply, PendingPartial, StdinAnsiParser};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -308,14 +308,16 @@ fn lone_trailing_esc_is_buffered_then_finalized_as_residue() {
out.has_partial_state,
"lone ESC must mark has_partial_state so the caller schedules a finalize tick"
);
- let drained = p.finalize();
+ assert_eq!(p.pending_partial(), PendingPartial::LoneEsc);
+ let drained = p.finalize_lone_esc();
assert_eq!(
drained,
vec![0x1b],
"finalize must release the parked ESC as keyboard residue"
);
// Subsequent finalize is a no-op once the parker is empty.
- assert!(p.finalize().is_empty());
+ assert!(p.finalize_lone_esc().is_empty());
+ assert_eq!(p.pending_partial(), PendingPartial::None);
}
#[test]
@@ -333,7 +335,10 @@ fn fragmented_osc_does_not_finalize_partial() {
let r2 = p.feed(&full[1..]);
assert!(r2.residue.is_empty(), "tail must complete the OSC");
assert_eq!(r1.replies.len() + r2.replies.len(), 1);
- assert!(p.finalize().is_empty(), "no partial left after completion");
+ assert!(
+ p.finalize_force().is_empty(),
+ "no partial left after completion"
+ );
}
#[test]
@@ -875,3 +880,348 @@ fn kitty_kbd_event_does_not_wedge_subsequent_forward_reply() {
);
}
}
+
+use crate::keyboard_parser::{KittyKeyboardParser, KittyParseOutcome};
+use zellij_utils::data::BareKey;
+
+fn assert_held_across_idle(parser: &mut StdinAnsiParser) {
+ assert_eq!(
+ parser.pending_partial(),
+ PendingPartial::ReplyInProgress,
+ "a payload-bearing partial must report ReplyInProgress while held"
+ );
+ assert!(
+ parser.finalize_lone_esc().is_empty(),
+ "the short idle finalize must NOT drain a reply-in-progress partial"
+ );
+ assert_eq!(
+ parser.pending_partial(),
+ PendingPartial::ReplyInProgress,
+ "the partial must still be held after a lone-esc finalize"
+ );
+}
+
+#[test]
+fn fragmented_osc4_reply_is_retained_not_leaked() {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(b"\x1b]4;1;rgb:1111");
+ assert!(
+ r1.residue.is_empty(),
+ "incomplete OSC 4 must not leak: {:?}",
+ r1.residue
+ );
+ assert!(r1.replies.is_empty());
+ assert_held_across_idle(&mut p);
+
+ let r2 = p.feed(b"/2222/3333\x1b\\");
+ assert!(
+ r2.residue.is_empty(),
+ "completion must not leak: {:?}",
+ r2.residue
+ );
+ assert_eq!(r2.replies.len(), 1, "the rejoined OSC 4 classifies once");
+ match &r2.replies[0] {
+ HostReply::ColorRegisters(regs) => {
+ assert_eq!(regs[0].0, 1);
+ assert_eq!(regs[0].1, "rgb:1111/2222/3333");
+ },
+ other => panic!("expected ColorRegisters, got {:?}", other),
+ }
+ assert_eq!(p.pending_partial(), PendingPartial::None);
+}
+
+#[test]
+fn fragmented_osc11_reply_is_retained_across_idle() {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(b"\x1b]11;rgb:0000/00");
+ assert!(r1.residue.is_empty());
+ assert_held_across_idle(&mut p);
+
+ let r2 = p.feed(b"00/0000\x1b\\");
+ assert!(r2.residue.is_empty());
+ assert_eq!(r2.replies.len(), 1);
+ matches!(r2.replies[0], HostReply::BackgroundColor(_));
+}
+
+#[test]
+fn esc_companion_chord_is_not_held_as_reply() {
+ let mut whole = StdinAnsiParser::new();
+ let out = whole.feed(b"\x1ba");
+ assert_eq!(out.residue, b"\x1ba");
+ assert!(out.replies.is_empty());
+
+ let mut split = StdinAnsiParser::new();
+ let r1 = split.feed(b"\x1b");
+ assert!(
+ r1.residue.is_empty(),
+ "lone ESC must park until disambiguated"
+ );
+ assert_eq!(split.pending_partial(), PendingPartial::LoneEsc);
+ let r2 = split.feed(b"a");
+ let mut combined = r1.residue.clone();
+ combined.extend_from_slice(&r2.residue);
+ assert_eq!(combined, b"\x1ba");
+ assert!(r2.replies.is_empty());
+}
+
+#[test]
+fn fragmented_ctrl_arrow_is_retained_then_passed_through() {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(b"\x1b[1;5");
+ assert!(r1.residue.is_empty(), "incomplete CSI must not leak");
+ assert_held_across_idle(&mut p);
+
+ let r2 = p.feed(b"C");
+ assert_eq!(r2.residue, b"\x1b[1;5C", "the arrow reassembles intact");
+ assert!(r2.replies.is_empty(), "a cursor key is never a host reply");
+}
+
+#[test]
+fn fragmented_sgr_mouse_is_retained_then_passed_through() {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(b"\x1b[<0;12;3");
+ assert!(r1.residue.is_empty());
+ assert_held_across_idle(&mut p);
+ let r2 = p.feed(b"4M");
+ assert_eq!(r2.residue, b"\x1b[<0;12;34M");
+ assert!(r2.replies.is_empty());
+}
+
+#[test]
+fn focus_event_is_residue_not_reply() {
+ let mut whole = StdinAnsiParser::new();
+ let out = whole.feed(b"\x1b[I");
+ assert_eq!(out.residue, b"\x1b[I");
+ assert!(out.replies.is_empty());
+
+ let mut split = StdinAnsiParser::new();
+ let r1 = split.feed(b"\x1b[");
+ assert!(r1.residue.is_empty());
+ let r2 = split.feed(b"I");
+ let mut combined = r1.residue.clone();
+ combined.extend_from_slice(&r2.residue);
+ assert_eq!(combined, b"\x1b[I");
+ assert!(r2.replies.is_empty());
+}
+
+#[test]
+fn kitty_keyboard_variants_whole_and_fragmented() {
+ let variants: Vec<&[u8]> = vec![
+ b"\x1b[97u",
+ b"\x1b[97;5u",
+ b"\x1b[97;1:3u",
+ b"\x1b[97:65;5u",
+ b"\x1b[97;5;65u",
+ b"\x1b[1;5A",
+ b"\x1b[1;2H",
+ b"\x1b[1;2P",
+ b"\x1b[1;2S",
+ b"\x1b[2;5~",
+ b"\x1b[3;5~",
+ b"\x1b[15;2~",
+ b"\x1b[27u",
+ b"\x1b[57362u",
+ b"\x1b[Z",
+ ];
+ for seq in variants {
+ let pretty = String::from_utf8_lossy(seq).replace('\x1b', "ESC");
+
+ let mut whole = StdinAnsiParser::new();
+ let out = whole.feed(seq);
+ assert_eq!(
+ out.residue, seq,
+ "{}: whole residue must equal the sequence",
+ pretty
+ );
+ assert!(out.replies.is_empty(), "{}: a key is never a reply", pretty);
+
+ for split in 2..seq.len() {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(&seq[..split]);
+ assert!(
+ r1.residue.is_empty(),
+ "{} split at {}: prefix must be held, not leaked: {:?}",
+ pretty,
+ split,
+ r1.residue
+ );
+ assert_eq!(
+ p.pending_partial(),
+ PendingPartial::ReplyInProgress,
+ "{} split at {}: prefix must be held",
+ pretty,
+ split
+ );
+ assert!(
+ p.finalize_lone_esc().is_empty(),
+ "{} split at {}: idle must not drain the held key",
+ pretty,
+ split
+ );
+ let r2 = p.feed(&seq[split..]);
+ let mut combined = r1.residue.clone();
+ combined.extend_from_slice(&r2.residue);
+ assert_eq!(
+ combined, seq,
+ "{} split at {}: reassembled residue must equal the sequence",
+ pretty, split
+ );
+ assert!(
+ r1.replies.is_empty() && r2.replies.is_empty(),
+ "{} split at {}: never classified as a reply",
+ pretty,
+ split
+ );
+ }
+ }
+}
+
+#[test]
+fn kitty_ctrl_a_trace_yields_one_event_not_a_literal_u() {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(b"\x1b[97;5");
+ assert!(r1.residue.is_empty());
+ assert_held_across_idle(&mut p);
+ let r2 = p.feed(b"u");
+ assert_eq!(r2.residue, b"\x1b[97;5u");
+
+ let mut kitty = KittyKeyboardParser::new();
+ match kitty.feed(&r2.residue) {
+ KittyParseOutcome::Complete(key) => {
+ assert!(
+ key.is_key_with_ctrl_modifier(BareKey::Char('a')),
+ "expected Ctrl-a, got {:?}",
+ key
+ );
+ },
+ other => panic!("expected a complete Ctrl-a, got {:?}", other),
+ }
+}
+
+#[test]
+fn bracketed_paste_preserves_embedded_escape_content() {
+ let mut p = StdinAnsiParser::new();
+ let mut chunk = Vec::new();
+ chunk.extend_from_slice(b"\x1b[200~");
+ chunk.extend_from_slice(b"echo \x1b]0;hi\x1b\\ done");
+ chunk.extend_from_slice(b"\x1b[201~");
+ let out = p.feed(&chunk);
+ assert!(
+ out.replies.is_empty(),
+ "paste content must not classify as a reply: {:?}",
+ out.replies
+ );
+ assert!(
+ out.desktop_notifications.is_empty(),
+ "an OSC inside a paste is literal, not a notification"
+ );
+ assert_eq!(
+ out.residue, chunk,
+ "the entire paste body, including the embedded OSC, passes through verbatim"
+ );
+ assert!(
+ out.residue.windows(6).any(|w| w == b"\x1b]0;hi"),
+ "the embedded OSC bytes must survive in residue"
+ );
+}
+
+#[test]
+fn paste_embedded_osc_is_not_eaten_when_fragmented() {
+ let full: Vec<u8> = b"\x1b[200~A\x1b]11;rgb:0/0/0\x1b\\B\x1b[201~".to_vec();
+ for split in 1..full.len() {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(&full[..split]);
+ let r2 = p.feed(&full[split..]);
+ let mut combined = r1.residue.clone();
+ combined.extend_from_slice(&r2.residue);
+ assert_eq!(
+ combined, full,
+ "split at {}: paste body (with embedded OSC) must pass through whole",
+ split
+ );
+ assert!(
+ r1.replies.is_empty() && r2.replies.is_empty(),
+ "split at {}: embedded OSC must not classify as a reply",
+ split
+ );
+ }
+}
+
+#[test]
+fn paste_markers_fragmented_still_toggle_paste_mode() {
+ for (head, tail) in [(b"\x1b[20".as_ref(), b"0~".as_ref())] {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(head);
+ assert!(r1.residue.is_empty(), "fragmented start marker is held");
+ let r2 = p.feed(tail);
+ let mut start = r1.residue.clone();
+ start.extend_from_slice(&r2.residue);
+ assert_eq!(
+ start, b"\x1b[200~",
+ "start marker passes through to residue"
+ );
+
+ let body = p.feed(b"\x1b]0;x\x1b\\");
+ assert_eq!(body.residue, b"\x1b]0;x\x1b\\");
+ assert!(body.replies.is_empty());
+ }
+ for (head, tail) in [(b"\x1b[20".as_ref(), b"1~".as_ref())] {
+ let mut p = StdinAnsiParser::new();
+ let _ = p.feed(b"\x1b[200~body");
+ let r1 = p.feed(head);
+ let r2 = p.feed(tail);
+ let mut end = r1.residue.clone();
+ end.extend_from_slice(&r2.residue);
+ assert_eq!(end, b"\x1b[201~", "end marker passes through to residue");
+
+ let after = p.feed(b"\x1b]11;rgb:1/2/3\x1b\\");
+ assert!(after.residue.is_empty());
+ assert_eq!(after.replies.len(), 1);
+ }
+}
+
+#[test]
+fn last_resort_force_drain_recovers_a_never_terminated_partial() {
+ let mut p = StdinAnsiParser::new();
+ let r1 = p.feed(b"\x1b]4;1;rgb:incomplete");
+ assert!(r1.residue.is_empty());
+ assert_eq!(p.pending_partial(), PendingPartial::ReplyInProgress);
+
+ let r2 = p.feed(b"and-more");
+ assert!(r2.residue.is_empty());
+ assert_eq!(p.pending_partial(), PendingPartial::ReplyInProgress);
+
+ let drained = p.finalize_force();
+ assert!(
+ drained.windows(14).any(|w| w == b"rgb:incomplete"),
+ "force-drain must recover the buffered partial: {:?}",
+ drained
+ );
+ assert!(
+ drained.windows(8).any(|w| w == b"and-more"),
+ "force-drain must recover bytes appended before the guard: {:?}",
+ drained
+ );
+ assert_eq!(
+ p.pending_partial(),
+ PendingPartial::None,
+ "the buffer is empty after a force-drain"
+ );
+}
+
+#[test]
+fn unsolicited_theme_notification_classifies_without_outstanding_query() {
+ let mut p = StdinAnsiParser::new();
+ let out = p.feed(b"\x1b[?997;1n");
+ assert!(
+ out.residue.is_empty(),
+ "the notification must not leak as residue"
+ );
+ assert_eq!(out.replies.len(), 1);
+ match &out.replies[0] {
+ HostReply::HostTerminalThemeChanged(mode) => {
+ assert_eq!(*mode, zellij_utils::data::HostTerminalThemeMode::Dark);
+ },
+ other => panic!("expected HostTerminalThemeChanged, got {:?}", other),
+ }
+}
diff --git a/zellij-client/src/stdin_handler.rs b/zellij-client/src/stdin_handler.rs
index c33e0dbf..26418188 100644
--- a/zellij-client/src/stdin_handler.rs
+++ b/zellij-client/src/stdin_handler.rs
@@ -1,11 +1,14 @@
use crate::keyboard_parser::{KittyKeyboardParser, KittyParseOutcome};
use crate::os_input_output::ClientOsApi;
-use crate::stdin_ansi_parser::StdinAnsiParser;
+use crate::stdin_ansi_parser::{PendingPartial, StdinAnsiParser};
#[cfg(windows)]
use crate::stdin_handler_windows::enable_vt_input;
use crate::InputInstruction;
use std::sync::{mpsc, Arc, Mutex};
-use std::time::Duration;
+use std::time::{Duration, Instant};
+
+const LONE_ESC_FLUSH_INTERVAL: Duration = Duration::from_millis(50);
+const PARTIAL_REPLY_FLUSH_GUARD: Duration = Duration::from_millis(1000);
use zellij_utils::{
channels::SenderWithContext,
vendored::termwiz::input::{InputEvent, InputParser},
@@ -99,9 +102,10 @@ pub(crate) fn stdin_loop(
}
});
let mut needs_finalization = false;
+ let mut reply_in_progress_since: Option<Instant> = None;
loop {
match if needs_finalization {
- stdin_rx.recv_timeout(Duration::from_millis(50))
+ stdin_rx.recv_timeout(LONE_ESC_FLUSH_INTERVAL)
} else {
stdin_rx
.recv()
@@ -134,20 +138,14 @@ pub(crate) fn stdin_loop(
let _ = send_input_instructions
.send(InputInstruction::DesktopNotificationResponse(payload));
}
- let has_partial = parse_output.has_partial_state;
let residue = parse_output.residue;
if residue.is_empty() {
- // If all bytes were consumed by the host-reply
- // parser, nothing to feed to the keyboard
- // parser. But if the host-reply parser is
- // sitting on a buffered partial sequence
- // (e.g. a lone trailing ESC waiting to be
- // disambiguated), schedule a finalize tick
- // so the idle drain releases it as keyboard
- // residue when no follow-up arrives.
- if has_partial {
- needs_finalization = true;
- }
+ schedule_finalization(
+ &stdin_ansi_parser,
+ false,
+ &mut needs_finalization,
+ &mut reply_in_progress_since,
+ );
continue;
}
current_buffer.append(&mut residue.clone());
@@ -168,6 +166,12 @@ pub(crate) fn stdin_loop(
true,
))
.unwrap();
+ schedule_finalization(
+ &stdin_ansi_parser,
+ false,
+ &mut needs_finalization,
+ &mut reply_in_progress_since,
+ );
continue;
},
KittyParseOutcome::Incomplete | KittyParseOutcome::NoMatch => {},
@@ -201,7 +205,12 @@ pub(crate) fn stdin_loop(
.unwrap();
}
- needs_finalization = true;
+ schedule_finalization(
+ &stdin_ansi_parser,
+ true,
+ &mut needs_finalization,
+ &mut reply_in_progress_since,
+ );
},
Err(e) => {
if e == "Session ended" {
@@ -215,13 +224,38 @@ pub(crate) fn stdin_loop(
}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
- finalize_events(
- &mut input_parser,
- &mut current_buffer,
- send_input_instructions.clone(),
- &stdin_ansi_parser,
- );
- needs_finalization = false;
+ let pending = stdin_ansi_parser.lock().unwrap().pending_partial();
+ match pending {
+ PendingPartial::ReplyInProgress => {
+ let elapsed = reply_in_progress_since
+ .map(|since| since.elapsed())
+ .unwrap_or_default();
+ if elapsed >= PARTIAL_REPLY_FLUSH_GUARD {
+ let drained = stdin_ansi_parser.lock().unwrap().finalize_force();
+ drain_partial_to_keyboard(
+ &mut input_parser,
+ &mut current_buffer,
+ send_input_instructions.clone(),
+ drained,
+ );
+ needs_finalization = false;
+ reply_in_progress_since = None;
+ } else {
+ needs_finalization = true;
+ }
+ },
+ _ => {
+ let drained = stdin_ansi_parser.lock().unwrap().finalize_lone_esc();
+ drain_partial_to_keyboard(
+ &mut input_parser,
+ &mut current_buffer,
+ send_input_instructions.clone(),
+ drained,
+ );
+ needs_finalization = false;
+ reply_in_progress_since = None;
+ },
+ }
},
Err(mpsc::RecvTimeoutError::Disconnected) => {
log::debug!("STDIN pump disconnected");
@@ -232,18 +266,31 @@ pub(crate) fn stdin_loop(
}
}
-fn finalize_events(
+fn schedule_finalization(
+ stdin_ansi_parser: &Arc<Mutex<StdinAnsiParser>>,
+ fed_termwiz: bool,
+ needs_finalization: &mut bool,
+ reply_in_progress_since: &mut Option<Instant>,
+) {
+ let pending = stdin_ansi_parser.lock().unwrap().pending_partial();
+ if fed_termwiz || pending != PendingPartial::None {
+ *needs_finalization = true;
+ }
+ if pending == PendingPartial::ReplyInProgress {
+ if reply_in_progress_since.is_none() {
+ *reply_in_progress_since = Some(Instant::now());
+ }
+ } else {
+ *reply_in_progress_since = None;
+ }
+}
+
+fn drain_partial_to_keyboard(
input_parser: &mut InputParser,
current_buffer: &mut Vec<u8>,
send_input_instructions: SenderWithContext<InputInstruction>,
- stdin_ansi_parser: &Arc<Mutex<StdinAnsiParser>>,
+ drained: Vec<u8>,
) {
- // Drain any speculatively-buffered partial host-reply bytes (a
- // lone trailing ESC, or an unterminated OSC/CSI prefix whose
- // follow-up never arrived). They become keyboard residue — same
- // path real keypress bytes take. Without this drain, a real Esc
- // press whose byte was parked under partial_osc would be lost.
- let drained = stdin_ansi_parser.lock().unwrap().finalize();
if !drained.is_empty() {
current_buffer.extend_from_slice(&drained);
}
@@ -256,8 +303,6 @@ fn finalize_events(
},
false,
);
- // Residue contains no OSC or whitelisted CSI reports — every
- // termwiz event drained on idle is a key/mouse/paste/etc.
for input_event in events {
send_input_instructions
.send(InputInstruction::KeyEvent(
@@ -277,13 +322,24 @@ fn build_startup_query_string() -> String {
// <ESC>]11;?<ESC>\ => get background color
// <ESC>]10;?<ESC>\ => get foreground color
// <ESC>[?2026$p => get synchronised output mode
- let mut query_string = String::from(
- "\u{1b}[14t\u{1b}[16t\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p",
- );
- // query colors
- // eg. <ESC>]4;5;?<ESC>\ => query color register number 5
- for i in 0..256 {
- query_string.push_str(&format!("\u{1b}]4;{};?\u{1b}\u{5c}", i));
+ String::from("\u{1b}[14t\u{1b}[16t\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::build_startup_query_string;
+
+ #[test]
+ fn startup_query_has_no_palette_register_loop() {
+ let query = build_startup_query_string();
+ assert_eq!(
+ query,
+ "\u{1b}[14t\u{1b}[16t\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p"
+ );
+ assert!(
+ !query.contains("\u{1b}]4;"),
+ "startup query must not contain OSC 4 palette-register probes: {:?}",
+ query
+ );
}
- query_string
}
diff --git a/zellij-integration-tests/tests/startup_host_query.rs b/zellij-integration-tests/tests/startup_host_query.rs
new file mode 100644
index 00000000..514df24f
--- /dev/null
+++ b/zellij-integration-tests/tests/startup_host_query.rs
@@ -0,0 +1,102 @@
+#![cfg(unix)]
+
+use std::thread::sleep;
+use std::time::Duration;
+
+use zellij_integration_tests::{
+ claim_first_terminal_and_wait_for_prompt, keys, start_zellij, TestSession,
+};
+
+const IDLE_BEYOND_SHORT_FLUSH: Duration = Duration::from_millis(150);
+
+fn lock_interface(zellij: &TestSession) {
+ zellij.send_stdin(&keys::ctrl('g'));
+ zellij.wait_until("interface locked", |grid_snapshot| {
+ grid_snapshot.contains("LOCK") && !grid_snapshot.contains("PANE")
+ });
+}
+
+fn unlock_interface(zellij: &TestSession) {
+ zellij.send_stdin(&keys::ctrl('g'));
+ zellij.wait_until("interface unlocked", |grid_snapshot| {
+ grid_snapshot.contains("PANE")
+ });
+}
+
+#[test]
+fn fragmented_attach_reply_burst_does_not_leak_into_focused_pane() {
+ let mut zellij = start_zellij();
+ let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
+ lock_interface(&zellij);
+
+ zellij.send_stdin(b"\x1b]4;1;rgb:abcd");
+ sleep(IDLE_BEYOND_SHORT_FLUSH);
+ zellij.send_stdin(b"/ef01/2345\x1b\\");
+
+ zellij.send_stdin(b"Z");
+ let stdin = terminal.wait_for_stdin("sentinel keystroke reached the pane", |stdin_bytes| {
+ stdin_bytes.contains(&b'Z')
+ });
+
+ assert!(
+ !stdin.windows(4).any(|window| window == b"rgb:"),
+ "host reply bytes leaked into the pane: {:?}",
+ stdin
+ );
+ assert!(
+ !stdin.contains(&b'/'),
+ "host reply payload leaked into the pane: {:?}",
+ stdin
+ );
+ assert!(
+ !stdin.contains(&b';'),
+ "host reply payload leaked into the pane: {:?}",
+ stdin
+ );
+
+ unlock_interface(&zellij);
+ zellij.quit();
+}
+
+#[test]
+fn fragmented_function_key_arrives_intact() {
+ let mut zellij = start_zellij();
+ let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
+ lock_interface(&zellij);
+
+ zellij.send_stdin(b"\x1b[1;5");
+ sleep(IDLE_BEYOND_SHORT_FLUSH);
+ zellij.send_stdin(b"C");
+
+ terminal.wait_for_stdin("ctrl-right reached the pane intact", |stdin_bytes| {
+ stdin_bytes.windows(6).any(|window| window == b"\x1b[1;5C")
+ });
+
+ unlock_interface(&zellij);
+ zellij.quit();
+}
+
+#[test]
+fn normal_typing_is_not_delayed_or_duplicated() {
+ let mut zellij = start_zellij();
+ let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
+ lock_interface(&zellij);
+
+ zellij.send_stdin(b"hello");
+ let stdin = terminal.wait_for_stdin("typed keys reached the pane", |stdin_bytes| {
+ stdin_bytes.windows(5).any(|window| window == b"hello")
+ });
+
+ let occurrences = stdin
+ .windows(5)
+ .filter(|window| *window == b"hello")
+ .count();
+ assert_eq!(
+ occurrences, 1,
+ "typed keys must reach the pane exactly once: {:?}",
+ stdin
+ );
+
+ unlock_interface(&zellij);
+ zellij.quit();
+}