fix: occasional garbage on attaching through slow connections (#5320)

* fix: occasional garbage on attaching through slow connections

* rustfmt

* add PR
This commit is contained in:
Aram Drevekenin
2026-06-30 16:48:37 +02:00
committed by GitHub
parent ba152f53fc
commit 6a6376cebb
5 changed files with 661 additions and 56 deletions
+1
View File
@@ -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)
+107 -11
View File
@@ -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) {
+354 -4
View File
@@ -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),
}
}
+97 -41
View File
@@ -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
}
@@ -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();
}