mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-12 02:27:24 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user