diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-06-30 16:48:37 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-30 16:48:37 +0200 |
| commit | 6a6376cebb0a3a85b39bbf3242dcd2ae58d82cc5 (patch) | |
| tree | cbb2c7f0e2deb5d7e941b9c8ef10d36c1bd2e2a4 | |
| parent | Fish completion delete session (#5317) (diff) | |
| download | sl0ppij-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.md | 1 | ||||
| -rw-r--r-- | zellij-client/src/stdin_ansi_parser.rs | 118 | ||||
| -rw-r--r-- | zellij-client/src/stdin_ansi_parser_tests.rs | 358 | ||||
| -rw-r--r-- | zellij-client/src/stdin_handler.rs | 138 | ||||
| -rw-r--r-- | zellij-integration-tests/tests/startup_host_query.rs | 102 |
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(); +} |
