diff --git a/Cargo.lock b/Cargo.lock index 677db12..b52ba2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -786,6 +786,7 @@ dependencies = [ "syn 2.0.119", "terminput", "terminput-crossterm", + "unicode-width", "vt100", ] diff --git a/Cargo.toml b/Cargo.toml index 5bb83f0..c27e85f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.151", features = ["preserve_order"] } terminput = "0.5.15" terminput-crossterm = "0.4.11" +# The width table alacritty_terminal's input uses, so a pane's zero-width chars are counted as it counts them. +# The crate is already in the tree via alacritty_terminal, so this adds no new dependency. +unicode-width = "0.2.2" [dev-dependencies] # tests/egress.rs reads the source to find every door out of nerd; all three are already in Cargo.lock. diff --git a/docs/privacy-and-security.md b/docs/privacy-and-security.md index ff9af87..c115538 100644 --- a/docs/privacy-and-security.md +++ b/docs/privacy-and-security.md @@ -46,3 +46,5 @@ An agent in a pane runs shell commands as you, so it could type `nerd --stop` or The way in is the message socket. Panes of admitted accounts reach it through `/tmp/nerd-/`, a folder only nerd's owner can write, checked before use, and reach nothing else of nerd. No path in nerd lets an agent change names, routes, isolation or that switch while nerd runs; from an isolated pane, no file can either. This guards against accidents, not against code that means it: a program that detaches from the pane (a double fork) is no longer its descendant, and any program running as you can stop the server with a signal anyway. + +What a pane prints is bounded: it cannot hold more of the server's memory than its `scrollback` and a 2 MiB update buffer allow, and it gets one slice of parsing at a time while other panes wait. An OSC string keeps at most 4096 characters, as GNOME VTE does, its number and `;` included, so a window title keeps 4094; a program saves at most 8 titles, the oldest going first; a repeat request (REP) writes at most one row's width; a character carries at most 30 zero-width characters (combining marks and the like), the limit Unicode's Stream-Safe format sets for marks; OSC 8 hyperlinks are not kept. Output is parsed 512 bytes at a time, and once a turn's 4 ms are spent the other panes go before the next piece; an update that just ended may take up to 8 ms more, so it is drawn whole. A synchronized update (mode 2026) is drawn when it ends, 150 ms after it began, or once it passes 2 MiB. A pane whose program exits closes once its last output is drawn; under a flood of costly sequences, after at most 2 s, without the rest. diff --git a/src/app/lifecycle.rs b/src/app/lifecycle.rs index 1164b08..e245619 100644 --- a/src/app/lifecycle.rs +++ b/src/app/lifecycle.rs @@ -7,6 +7,12 @@ const SCREEN_TEXT_LIMIT: usize = 1 << 20; /// How long an exit notice waits for the pane's last output. const EXIT_WAIT: Duration = Duration::from_millis(100); +// ceiling: a pane whose program left closes 2 s after its exit even with output still unparsed (a flood of costly sequences) +const DRAIN_CAP: Duration = Duration::from_secs(2); + +/// How long an Update or exit parses what every pane still has, all panes together; the rest is not drawn. +const DRAIN_BUDGET: Duration = Duration::from_secs(1); + /// Chunks a pane's output takes per round, so a flooding pane cannot hold back a quiet one. pub(super) const QUANTUM: usize = 16; @@ -100,9 +106,10 @@ impl App { let mut out = Vec::new(); let trace = self.trace; let theme = self.theme; + let deadline = Some(Instant::now() + DRAIN_BUDGET); for p in self.spaces.iter_mut().flat_map(|s| s.tabs.iter_mut()).flat_map(|t| t.panes.values_mut()) { // Only what is queued now: a pane that keeps printing must not hold the Update. - p.pump(crate::pty::QUEUE, trace, &theme); + p.drain(deadline, trace, &theme); let fd = p.pty.export_fd()?; let (since, unseen) = p.term.activity(); crate::paths::write_private(&crate::session::handover_dump_path(p.id), "tmp", &p.term.dump())?; @@ -195,8 +202,9 @@ impl App { } let trace = self.trace; let theme = self.theme; + let deadline = Some(Instant::now() + DRAIN_BUDGET); for p in self.spaces.iter_mut().flat_map(|s| s.tabs.iter_mut()).flat_map(|t| t.panes.values_mut()) { - p.pump(crate::pty::QUEUE, trace, &theme); + p.drain(deadline, trace, &theme); let text = p.term.text(SCREEN_TEXT_LIMIT); if let Err(e) = crate::paths::write_private(&crate::session::screen_path(p.id), "tmp", &text) { crate::log::error(&format!("screens: write failed for pane {}: {e}", p.id)); @@ -219,12 +227,13 @@ impl App { } } let mut changed = false; + let deadline = Some(start + budget); loop { let mut more = false; for p in self.spaces.iter_mut().flat_map(|s| s.tabs.iter_mut()).flat_map(|t| t.panes.values_mut()) { - let took = p.pump(QUANTUM, trace, &theme); - changed |= took > 0; - more |= took == QUANTUM; + let (parsed, left) = p.pump(QUANTUM, deadline, trace, &theme); + changed |= parsed; + more |= left; } if !more || start.elapsed() >= budget { return (changed, more); @@ -325,18 +334,27 @@ impl App { } } - /// Closes a pane only once its reader ended or the wait ran out, so its last lines reach the screen. + /// Closes a pane once its reader ended or the wait ran out, and `pump_pty` has parsed its last output, so its + /// last lines reach the screen; under a flood of costly sequences, DRAIN_CAP after the exit, without the rest. pub fn settle_exits(&mut self) -> bool { - let trace = self.trace; - let theme = self.theme; + self.settle_exits_at(Instant::now()) + } + + pub(super) fn settle_exits_at(&mut self, now: Instant) -> bool { let mut closed = false; for (id, until) in std::mem::take(&mut self.exits) { let Some(p) = self.pane_mut(id) else { self.exit_how.remove(&id); continue; }; - p.pump(crate::pty::QUEUE, trace, &theme); - if p.pty.ended || Instant::now() >= until { + // Its program is gone: an update it left open will not end. + p.term.release_hold(); + let cut = now >= until.checked_sub(EXIT_WAIT).unwrap_or(until) + DRAIN_CAP; + if (p.pty.ended || now >= until) && ((!p.term.queued() && !p.behind) || cut) { + if p.term.queued() || p.behind { + crate::log::warn_every("pane output dropped", Duration::from_secs(10), &format!("pane {id}: its program left and its last output took over 2 s to draw; the rest was dropped")); + p.term.discard(); + } // The pane's process is its shell: when that leaves, there is no pane left. let how = self.exit_how.remove(&id).unwrap_or_else(|| "it left during the update, how is not known".into()); self.drop_pane(id, Some(&how)); @@ -424,9 +442,12 @@ impl App { let mut timers = false; let mut stalled = false; let mut panes = HashMap::new(); + let now = Instant::now(); for (si, sp) in self.spaces.iter_mut().enumerate() { for (ti, tab) in sp.tabs.iter_mut().enumerate() { for p in tab.panes.values_mut() { + // An update that never ends is drawn as it is; the next pump_pty parses it. + p.term.release_stale(now); stalled |= std::mem::take(&mut p.pty.stalled); let before = (p.term.shown(), p.agent.clone(), p.command.clone(), p.activity_key); changed |= p.term.tick_state(); diff --git a/src/app/mod.rs b/src/app/mod.rs index aa8e411..c86aedd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1495,6 +1495,8 @@ mod tests { activity_key: [None, None], mouse_left_on: false, isolation: Default::default(), + unanswered: None, + behind: false, } } @@ -5172,7 +5174,195 @@ mod tests { assert_eq!(app.pump_pty(Duration::ZERO), (true, true), "one round leaves the flood queued"); assert!(app.pane(2).unwrap().term.screen().contents().contains("quiet"), "the quiet pane had its turn"); let p = app.spaces[0].tabs[0].panes.get_mut(&1).unwrap(); - assert_eq!(std::iter::from_fn(|| p.pty.next()).count(), crate::pty::QUEUE - QUANTUM, "the flood gave up one quantum"); + assert!(p.term.queued(), "the flood stopped inside its first chunk"); + assert_eq!(std::iter::from_fn(|| p.pty.next()).count(), crate::pty::QUEUE - 1, "the flood gave up after one slice"); + } + + #[test] + fn three_flooding_panes_each_parse_one_slice_a_round() { + let mut app = with_one_space(); + let mut keep = Vec::new(); + for (tab, id) in [(0, 1), (0, 2), (1, 3)] { + let (pane, tx) = fed_pane(&app, id, None); + for _ in 0..crate::pty::QUEUE { + tx.send(vec![b'y'; 8192]).unwrap(); + } + app.spaces[0].tabs[tab].panes.insert(id, pane); + keep.push(tx); + } + let (quiet, qtx) = fed_pane(&app, 4, None); + app.spaces[0].tabs[1].panes.insert(4, quiet); + qtx.send(b"quiet".to_vec()).unwrap(); + assert_eq!(app.pump_pty(Duration::ZERO), (true, true)); + for id in [1, 2, 3] { + assert_eq!(app.pane(id).unwrap().term.queued_len(), 8192 - crate::term::SLICE, "pane {id}"); + } + assert!(app.pane(4).unwrap().term.screen().contents().contains("quiet")); + } + + #[test] + fn a_flood_in_chunks_smaller_than_a_slice_gives_way_after_one() { + let mut app = with_one_space(); + let (flood, tx) = fed_pane(&app, 1, None); + app.spaces[0].tabs[0].panes.insert(1, flood); + for _ in 0..crate::pty::QUEUE { + tx.send(vec![b'y'; crate::term::SLICE / 2]).unwrap(); + } + assert_eq!(app.pump_pty(Duration::ZERO), (true, true)); + let p = app.spaces[0].tabs[0].panes.get_mut(&1).unwrap(); + assert_eq!(std::iter::from_fn(|| p.pty.next()).count(), crate::pty::QUEUE - 1); + } + + /// A stream sent as 8 KiB chunks and pumped to the end, each turn with `deadline`. + fn pumped(app: &App, raw: &[u8], deadline: Option) -> (String, Vec) { + let (mut pane, tx) = fed_pane(app, 1, None); + pane.term = Term::new(120, 40, 1000); + let mut chunks = raw.chunks(8192); + let theme = app.theme; + loop { + let sent = chunks.next().map(|c| tx.send(c.to_vec()).unwrap()).is_some(); + let (_, left) = pane.pump(QUANTUM, deadline, false, &theme); + if !sent && !left { + break; + } + } + pane.term.finish(); + (pane.term.full_state(), pane.term.dump()) + } + + #[test] + fn output_parsed_a_slice_a_turn_ends_as_it_does_parsed_at_once() { + let app = with_one_space(); + for (name, raw) in crate::term::corpus() { + let sliced = pumped(&app, &raw, Some(Instant::now())); + let whole = pumped(&app, &raw, None); + assert!(sliced == whole, "{name}"); + } + } + + #[test] + fn a_chunk_is_answered_once_after_its_last_slice() { + use std::io::Read; + let mut app = with_one_space(); + let (ours, theirs) = std::os::unix::net::UnixStream::pair().unwrap(); + let (pane, tx) = fed_pane(&app, 1, Some(std::fs::File::from(std::os::fd::OwnedFd::from(theirs)))); + app.spaces[0].tabs[0].panes.insert(1, pane); + let chunk = [&b"\r\n"[..], &vec![b'x'; 3 * crate::term::SLICE], b"\x1b[6n"].concat(); + let mut want = Term::new(80, 24, 100); + want.feed(&chunk); + let (row, col) = want.screen().cursor_position(); + tx.send(chunk).unwrap(); + let mut turns = 0; + while app.pump_pty(Duration::ZERO).1 { + turns += 1; + } + assert!(turns >= 3, "{turns} turns"); + ours.set_read_timeout(Some(Duration::from_millis(300))).unwrap(); + let mut got = Vec::new(); + let mut b = [0u8; 64]; + while let Ok(n) = (&ours).read(&mut b) { + got.extend_from_slice(&b[..n]); + } + assert_eq!(String::from_utf8_lossy(&got), format!("\x1b[{};{}R", row + 1, col + 1), "one answer, from after the whole chunk"); + } + + #[test] + fn an_update_that_ends_is_parsed_whole_beside_a_flood() { + let mut app = with_one_space(); + let (flood, ftx) = fed_pane(&app, 1, None); + let (frame, tx) = fed_pane(&app, 2, None); + app.spaces[0].tabs[0].panes.insert(1, flood); + app.spaces[0].tabs[1].panes.insert(2, frame); + for _ in 0..crate::pty::QUEUE { + ftx.send(b"\x1b#8".repeat(2730)).unwrap(); + } + let update = [&b"\x1b[?2026h"[..], &b"0123456789abcdef\r\n".repeat(40 * 1024 / 18), b"\x1b[?2026l"].concat(); + for c in update.chunks(8192) { + tx.send(c.to_vec()).unwrap(); + } + app.pump_pty(Duration::ZERO); + assert!(app.pane(2).unwrap().term.holding(), "the update is held while it lasts"); + // The round that takes its end: the update is released and parsed in that same turn. + for _ in 0..8 { + app.pump_pty(Duration::ZERO); + if !app.pane(2).unwrap().term.holding() { + break; + } + } + let t = &app.pane(2).unwrap().term; + assert!(!t.holding() && !t.queued(), "the ended update was parsed whole in the turn that took its end"); + assert!(t.screen().contents().contains("0123456789abcdef")); + assert!(app.pane(1).unwrap().term.queued(), "the flood still gave way after one slice"); + } + + /// S6: `cargo test --release --bin nerd -- --ignored --nocapture measure_rounds`. + #[test] + #[ignore] + fn measure_rounds() { + let mut app = with_one_space(); + let mut keep = Vec::new(); + for id in 1..=4 { + let (mut pane, tx) = fed_pane(&app, id, None); + pane.term = Term::new(600, 170, 1000); + for _ in 0..8 { + tx.send(b"\x1b#8".repeat(2730)).unwrap(); + } + app.spaces[0].tabs[id % 2].panes.insert(id, pane); + keep.push(tx); + } + let frame = crate::term::corpus().into_iter().find(|(n, _)| n == "agent-wide").unwrap().1; + let (start, end) = (frame.windows(8).position(|w| w == b"\x1b[?2026h").unwrap(), frame.windows(8).position(|w| w == b"\x1b[?2026l").unwrap() + 8); + let (mut pane, tx) = fed_pane(&app, 5, None); + pane.term = Term::new(600, 170, 1000); + app.spaces[0].tabs[1].panes.insert(5, pane); + tx.send(frame[start..end].to_vec()).unwrap(); + let (mut longest, mut rounds) = (Duration::ZERO, 0); + loop { + let t = Instant::now(); + let (_, more) = app.pump_pty(PUMP_BUDGET); + longest = longest.max(t.elapsed()); + rounds += 1; + if rounds == 1 { + println!("frame of {} bytes beside four DECALN floods: parsed whole in round 1: {}", end - start, !app.pane(5).unwrap().term.queued()); + } + if !more { + break; + } + } + println!("four panes flooding DECALN at 600x170: {rounds} rounds, longest pump_pty {:.1} ms", longest.as_secs_f64() * 1e3); + } + + #[test] + fn a_pane_whose_program_left_parses_its_flood_a_turn_at_a_time_then_closes() { + let mut app = with_one_space(); + let (mut pane, tx) = fed_pane(&app, 1, None); + pane.argv = vec!["claude".into()]; + app.spaces[0].tabs[0].panes.insert(1, pane); + for _ in 0..crate::pty::QUEUE { + tx.send(b"\x1b#8".repeat(2730)).unwrap(); + } + app.exited((1, None)); + let t0 = Instant::now(); + app.pump_pty(Duration::ZERO); + assert!(!app.settle_exits_at(t0 + Duration::from_millis(150)), "past its wait, output still queued: it stays"); + assert!(app.pane(1).unwrap().term.queued()); + drop(tx); + while app.pump_pty(Duration::from_millis(50)).1 {} + assert!(app.settle_exits_at(t0 + Duration::from_millis(200)), "the reader ended and the queue drained"); + assert!(app.pane(1).is_none()); + + let (mut pane, tx) = fed_pane(&app, 2, None); + pane.argv = vec!["claude".into()]; + app.spaces[0].tabs[0].panes.insert(2, pane); + for _ in 0..crate::pty::QUEUE { + tx.send(b"\x1b#8".repeat(2730)).unwrap(); + } + app.exited((2, None)); + let t0 = Instant::now(); + app.pump_pty(Duration::ZERO); + assert!(!app.settle_exits_at(t0 + Duration::from_millis(1900))); + assert!(app.settle_exits_at(t0 + Duration::from_millis(2100)), "2 s after the exit the rest goes"); + assert!(app.pane(2).is_none()); } #[test] @@ -5184,7 +5374,7 @@ mod tests { app.spaces[0].tabs[0].panes.insert(1, pane); tx.send(b"ab\x1b[6n".to_vec()).unwrap(); tx.send(b"\r\n\x1b[6n".to_vec()).unwrap(); - app.pump_pty(Duration::ZERO); + while app.pump_pty(Duration::ZERO).1 {} ours.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); let mut got = Vec::new(); while got.len() < 12 { @@ -5202,12 +5392,15 @@ mod tests { app.spaces[0].tabs[0].panes.insert(1, pane); tx.send(b"queued before the notice\r\n".to_vec()).unwrap(); app.exited((1, None)); + app.pump_pty(PUMP_BUDGET); assert!(!app.settle_exits(), "its reader is still there"); tx.send(b"resume with: claude --resume x".to_vec()).unwrap(); + app.pump_pty(PUMP_BUDGET); assert!(!app.settle_exits()); let text = app.pane(1).unwrap().term.screen().contents(); assert!(text.contains("queued before the notice") && text.contains("claude --resume x"), "{text}"); drop(tx); + app.pump_pty(PUMP_BUDGET); assert!(app.settle_exits(), "the reader ended: the pane goes at once"); assert!(app.pane(1).is_none()); @@ -5398,7 +5591,7 @@ mod tests { let mut first_drop = None; for i in 0..crate::pty::WRITES + 64 { tx.send(b"\x1b[6n".repeat(2000)).unwrap(); - app.pump_pty(Duration::ZERO); + while app.pump_pty(Duration::ZERO).1 {} if first_drop.is_none() && log().contains("replies dropped") { first_drop = Some(i); } diff --git a/src/app/model.rs b/src/app/model.rs index 34a8eb1..568b86f 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -23,6 +23,10 @@ pub struct Pane { pub(super) mouse_left_on: bool, /// What the pane's programs may reach: files and network. pub isolation: crate::policy::Isolation, + /// The chunk being parsed, answered once its last slice is. + pub(super) unanswered: Option>, + /// The last turn left output waiting: in the pty, or queued in the term. + pub(super) behind: bool, } impl Pane { @@ -41,32 +45,62 @@ impl Pane { activity_key: [None, None], mouse_left_on: false, isolation: Default::default(), + unanswered: None, + behind: false, } } - /// Feeds up to `n` queued chunks one at a time, since `answers` reads the cursor after each; returns how many. - pub(super) fn pump(&mut self, n: usize, trace: bool, theme: &crate::render::Theme) -> usize { + /// Parses what is queued, then takes up to `n` chunks one at a time, since `answers` reads the cursor after each; + /// stops between slices once `deadline` passed. Returns (anything taken or parsed, output still waiting). + pub(super) fn pump(&mut self, n: usize, deadline: Option, trace: bool, theme: &crate::render::Theme) -> (bool, bool) { let rgb = |c: ratatui::style::Color| match c { ratatui::style::Color::Rgb(r, g, b) => (r, g, b), _ => (0, 0, 0), }; let (fg, bg) = (rgb(theme.text), rgb(theme.ground)); let mut took = 0; - while took < n - && let Some(bytes) = self.pty.next() - { + let mut parsed = false; + let left = loop { + if self.term.queued() { + parsed = true; + if !self.term.parse(deadline) { + break true; + } + } + if let Some(bytes) = self.unanswered.take() { + self.watch_mouse(); + let ans = self.term.answers(&bytes, fg, bg); + if !ans.is_empty() { + self.pty.reply(&ans); + } + } + // Past the deadline a turn that has parsed takes no next chunk: one slice per pane per round. + if took == n || (parsed && deadline.is_some_and(|d| Instant::now() >= d)) { + break true; + } + let Some(bytes) = self.pty.next() else { break false }; if trace && bytes.windows(3).any(|w| w == b"\x1b]4" || w == b"\x1b]1") { crate::log::debug(&format!("trace pane {} OSC: {} bytes", self.id, bytes.len())); } - self.term.feed(&bytes); - self.watch_mouse(); - let ans = self.term.answers(&bytes, fg, bg); - if !ans.is_empty() { - self.pty.reply(&ans); - } + self.term.take(&bytes); + self.unanswered = Some(bytes); took += 1; + }; + let hits = self.term.take_hits(); + if hits != 0 { + let line = format!("pane {}: output over nerd's limits was cut ({})", self.id, crate::term::describe_hits(hits)); + crate::log::warn_every("pane output cut", Duration::from_secs(10), &line); } - took + self.behind = left; + (parsed || took > 0, left) + } + + /// What is queued now, an open update included, parsed before nerd reads the screen at an Update or exit. + pub(super) fn drain(&mut self, deadline: Option, trace: bool, theme: &crate::render::Theme) { + self.term.release_hold(); + self.pump(crate::pty::QUEUE, deadline, trace, theme); + self.term.release_hold(); + self.term.parse(deadline); } /// A program killed with the mouse on never turns it off, and the shell after it would get every move as typed text. diff --git a/src/app/snapshots.rs b/src/app/snapshots.rs index d1dcefb..f89287f 100644 --- a/src/app/snapshots.rs +++ b/src/app/snapshots.rs @@ -248,6 +248,8 @@ pub(crate) fn agent_pane(app: &App, id: PaneId, state: AgentState, subs: usize) activity_key: [None, None], mouse_left_on: false, isolation: Default::default(), + unanswered: None, + behind: false, }; p.term.set_hook_state(Some(state)); p.term.tick_state(); diff --git a/src/log.rs b/src/log.rs index e30ab7e..107b46e 100644 --- a/src/log.rs +++ b/src/log.rs @@ -140,10 +140,15 @@ pub fn escape(s: &str) -> std::borrow::Cow<'_, str> { /// A warning a plugin can cause at will: at most one a second of each kind, or it could roll the log over. pub fn warn_limited(kind: &'static str, line: &str) { + warn_every(kind, std::time::Duration::from_secs(1), line); +} + +/// A warning written at most once per `every` of each kind. +pub fn warn_every(kind: &'static str, every: std::time::Duration, line: &str) { static LAST: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); let held = match last.iter_mut().find(|(k, _, _)| *k == kind) { - Some((_, at, held)) if at.elapsed() < std::time::Duration::from_secs(1) => { + Some((_, at, held)) if at.elapsed() < every => { *held += 1; return; } diff --git a/src/term.rs b/src/term.rs index d2f3971..6e413e6 100644 --- a/src/term.rs +++ b/src/term.rs @@ -7,6 +7,11 @@ use alacritty_terminal::term::{MIN_COLUMNS, TermMode}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::vte::ansi::{self, NamedColor}; +mod bounds; +pub use bounds::describe as describe_hits; +#[cfg(test)] +pub(crate) use bounds::tests::corpus; + /// `Done` is `Idle` for a pane nobody has looked at since it finished; the mark /// clears itself once a human has looked. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -58,10 +63,15 @@ pub enum WheelRouting { /// Keeps the title a program sets; nerd answers queries itself, so the emulator's replies are dropped. #[derive(Clone, Default)] -struct Listener(std::sync::Arc>>); +struct Listener( + std::sync::Arc>>, + #[cfg(test)] std::sync::Arc>>, +); impl alacritty_terminal::event::EventListener for Listener { fn send_event(&self, event: alacritty_terminal::event::Event) { + #[cfg(test)] + self.1.lock().unwrap().push(format!("{event:?}")); if let alacritty_terminal::event::Event::Title(t) = event && let Ok(mut slot) = self.0.lock() { @@ -122,7 +132,28 @@ impl Dimensions for Size { pub struct Term { term: alacritty_terminal::Term, - parser: ansi::Processor, + parser: ansi::Processor, + filter: bounds::Filter, + titles: bounds::Titles, + /// The caps that fired since the last `take_hits`. + hits: u8, + /// The filter's output and its update marks, kept between chunks for their capacity. + out: Vec, + marks: Vec<(usize, bool)>, + /// Filtered bytes ready to parse, from `at` on. + queue: Vec, + at: usize, + /// A synchronized update being held, and since when; never parsed while held. + held: Vec, + held_since: Option, + /// Where in `queue` an update released at its end ends, and when its parse began. + frame_end: Option, + frame_started: Option, + /// Continuation bytes the last slice's cut character still needs. + pending: u8, + /// vte as it was before the bounds, so a test can compare the two. + #[cfg(test)] + legacy: Option, events: Listener, title: Option, last_output: std::time::Instant, @@ -173,6 +204,17 @@ const ANSWER_AFTER: std::time::Duration = std::time::Duration::from_millis(300); const SUSTAINED: std::time::Duration = std::time::Duration::from_secs(1); /// An agent without a hook: the silence after work that makes it done, longer than its pauses between steps. const AGENT_QUIET: std::time::Duration = std::time::Duration::from_secs(3); +// ceiling: an update over 2 MiB (vte's own limit) is drawn in parts +const HOLD_CAP: usize = 2 << 20; +/// vte's own wait for an update that does not end. +const HOLD_FOR: std::time::Duration = std::time::Duration::from_millis(150); +// ceiling: one slice of screen-sized sequences runs whole (S6: ≤ 50 ms at 600×170); a per-pane parse thread (option E) past it +pub const SLICE: usize = 512; +// ceiling: an update released at its end parses on past the round's deadline for two budgets, so it is drawn whole +const FRAME_ALLOWANCE: std::time::Duration = crate::app::PUMP_BUDGET.saturating_mul(2); +/// What an emptied queue keeps of its capacity. +const QUEUE_KEEP: usize = 64 << 10; +const MOUSE_OFF: &[u8] = b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l"; impl Term { pub fn new(cols: u16, rows: u16, scrollback: usize) -> Self { @@ -181,6 +223,20 @@ impl Term { Self { term: alacritty_terminal::Term::new(config, &Size(cols.max(MIN_COLUMNS as u16), rows.max(1)), events.clone()), parser: ansi::Processor::new(), + filter: Default::default(), + titles: Default::default(), + hits: 0, + out: Vec::new(), + marks: Vec::new(), + queue: Vec::new(), + at: 0, + held: Vec::new(), + held_since: None, + frame_end: None, + frame_started: None, + pending: 0, + #[cfg(test)] + legacy: None, events, title: None, // Nothing printed yet: a fresh pane is not "working", or its first tick would pass for activity. @@ -203,15 +259,76 @@ impl Term { } } + /// Today's path without the bounds: vte with its own synchronized-update buffer straight into alacritty. + #[cfg(test)] + pub fn unbounded(cols: u16, rows: u16, scrollback: usize) -> Self { + Self { legacy: Some(ansi::Processor::new()), ..Self::new(cols, rows, scrollback) } + } + + /// Everything a program can change, links masked (nerd drops them), for comparing two Terms. + #[cfg(test)] + pub fn full_state(&self) -> String { + use std::fmt::Write; + let g = self.term.grid(); + let mut s = String::new(); + for l in -(g.history_size() as i32)..g.screen_lines() as i32 { + for c in 0..g.columns() { + let mut cell = g[Line(l)][Column(c)].clone(); + cell.set_hyperlink(None); + write!(s, "{cell:?}|").unwrap(); + } + s.push('\n'); + } + let c = &g.cursor; + let style = self.term.cursor_style(); + writeln!(s, "mode {:?} cursor {:?} wrap {} style {style:?} title {:?} view {:?}", self.term.mode().bits(), c.point, c.input_needs_wrap, self.title, (g.display_offset(), g.history_size())).unwrap(); + for i in 0..alacritty_terminal::term::color::COUNT { + write!(s, "{:?},", self.term.colors()[i]).unwrap(); + } + writeln!(s, "\nevents {:?}", self.events.1.lock().unwrap()).unwrap(); + s + } + + /// The end of a test's stream: an update still open is parsed, on either path. + #[cfg(test)] + pub fn finish(&mut self) { + if let Some(legacy) = self.legacy.as_mut() { + legacy.stop_sync(&mut self.term); + self.after_slice(); + } else { + self.release_hold(); + self.parse(None); + } + } + + #[cfg(test)] + pub fn holding(&self) -> bool { + self.held_since.is_some() + } + + #[cfg(test)] + pub fn queued_len(&self) -> usize { + self.queue.len() - self.at + } + + /// `take` and `parse` at once, for a test that feeds and reads. + #[cfg(test)] pub fn feed(&mut self, bytes: &[u8]) { + self.take(bytes); + self.parse(None); + } + + /// A chunk of output in: what its raw bytes ask of nerd itself, then filtered into the queue or the hold. + pub fn take(&mut self, bytes: &[u8]) { + debug_assert!(self.queue[self.at..].is_empty(), "a chunk is taken only once the last one is parsed"); if let Some(p) = crate::pointer::requested(bytes) { self.pointer = p; } if let Some(on) = private_mode(bytes, b"2031") { self.theme_notify = on; } - self.advance(bytes); let now = std::time::Instant::now(); + self.enqueue(bytes, now); if self.last_output.elapsed() >= IDLE_AFTER { self.burst_from = now; } @@ -223,12 +340,152 @@ impl Term { /// Old text put back after a restart: drawn, but not output, so it is neither activity nor "done". pub fn replay(&mut self, bytes: &[u8]) { - self.advance(bytes); + self.enqueue(bytes, std::time::Instant::now()); + self.parse(None); } - fn advance(&mut self, bytes: &[u8]) { + /// Filtered bytes go to the hold while an update is open and to the queue otherwise, in the order written. + fn enqueue(&mut self, bytes: &[u8], now: std::time::Instant) { + #[cfg(test)] + if self.legacy.is_some() { + self.queue.extend_from_slice(bytes); + return; + } + let (mut out, mut marks) = (std::mem::take(&mut self.out), std::mem::take(&mut self.marks)); + out.clear(); + marks.clear(); + let cols = self.term.columns() as u16; + if self.held_since.is_none() { + // Straight into the queue; only what follows the first mark is sorted below. + self.filter.pass(bytes, cols, &mut self.hits, &mut self.queue, &mut marks); + let Some(&(first, _)) = marks.first() else { + (self.out, self.marks) = (out, marks); + return; + }; + out.extend_from_slice(&self.queue[first..]); + self.queue.truncate(first); + marks.iter_mut().for_each(|m| m.0 -= first); + } else { + self.filter.pass(bytes, cols, &mut self.hits, &mut out, &mut marks); + } + let mut from = 0; + for (to, begin) in marks.iter().map(|&(o, b)| (o, Some(b))).chain([(out.len(), None)]) { + let piece = &out[from..to]; + from = to; + if self.held_since.is_some() && self.held.len() + piece.len() > HOLD_CAP { + self.release(false); + } + if self.held_since.is_some() { &mut self.held } else { &mut self.queue }.extend_from_slice(piece); + match begin { + Some(true) if self.held_since.is_none() => self.held_since = Some(now), + Some(false) if self.held_since.is_some() => self.release(true), + _ => {} + } + } + (self.out, self.marks) = (out, marks); + } + + /// The held update joins the queue behind what is already there; `frame`: it ended, so it may be drawn whole. + fn release(&mut self, frame: bool) { + if self.queue[self.at..].is_empty() { + self.queue.clear(); + self.at = 0; + std::mem::swap(&mut self.queue, &mut self.held); + } else { + self.queue.extend_from_slice(&self.held); + self.held.clear(); + } + self.held.shrink_to(QUEUE_KEEP); + self.held_since = None; + if frame { + if self.frame_end.is_none() { + self.frame_started = None; + } + self.frame_end = Some(self.queue.len()); + } + } + + /// An open update goes to the queue as it is: its program left, or nerd is about to read the screen. + pub fn release_hold(&mut self) { + if self.held_since.is_some() { + self.release(false); + } + } + + /// An update open for HOLD_FOR is drawn as it is, as vte would; true when one was. + pub fn release_stale(&mut self, now: std::time::Instant) -> bool { + let stale = self.held_since.is_some_and(|t| now >= t + HOLD_FOR); + if stale { + self.release(false); + } + stale + } + + /// Whether filtered bytes wait to be parsed. + pub fn queued(&self) -> bool { + self.at < self.queue.len() + } + + /// The caps that fired since the last call, as `describe_hits` names them. + pub fn take_hits(&mut self) -> u8 { + std::mem::take(&mut self.hits) + } + + /// Feeds the queue to vte in slices of SLICE, stopping between them once `deadline` passed; true when it is empty. + pub fn parse(&mut self, deadline: Option) -> bool { + self.parse_in(deadline, SLICE) + } + + fn parse_in(&mut self, deadline: Option, slice: usize) -> bool { + #[cfg(test)] + if let Some(legacy) = self.legacy.as_mut() { + legacy.advance(&mut self.term, &self.queue[self.at..]); + self.queue.clear(); + self.at = 0; + self.after_slice(); + return true; + } + while self.at < self.queue.len() { + if self.frame_end.is_some_and(|e| self.at < e) && self.frame_started.is_none() { + self.frame_started = Some(std::time::Instant::now()); + } + let start = self.at; + let cont = |b: &u8| (0x80..0xc0).contains(b); + // vte 0.15 lib.rs:687-701 drops what follows a character completed across two calls: a slice never ends + // inside one, and the rest of one cut at the queue's end goes to vte alone. + let end = if self.pending > 0 && cont(&self.queue[start]) { + start + self.queue[start..].iter().take(self.pending as usize).take_while(|b| cont(b)).count() + } else { + let mut end = (start + slice).min(self.queue.len()); + while end < self.queue.len() && end < start + slice + 3 && cont(&self.queue[end]) { + end += 1; + } + end + }; + let mut guard = bounds::Guard { term: &mut self.term, titles: &mut self.titles, hits: &mut self.hits }; + self.parser.advance(&mut guard, &self.queue[start..end]); + self.pending = bounds::pending(&self.queue[start..end], self.pending); + self.at = end; + self.after_slice(); + if self.frame_end.is_some_and(|e| self.at >= e) { + (self.frame_end, self.frame_started) = (None, None); + } + let deadline = match (deadline, self.frame_started) { + (Some(d), Some(f)) => Some(d.max(f + FRAME_ALLOWANCE)), + (d, _) => d, + }; + if self.at < self.queue.len() && deadline.is_some_and(|d| std::time::Instant::now() >= d) { + return false; + } + } + self.queue.clear(); + self.queue.shrink_to(QUEUE_KEEP); + self.at = 0; + true + } + + fn after_slice(&mut self) { self.edits += 1; - self.parser.advance(&mut self.term, bytes); if let Ok(mut slot) = self.events.0.lock() && let Some(t) = slot.take() { @@ -238,6 +495,14 @@ impl Term { self.scroll = self.term.grid().display_offset(); } + /// What is queued or held goes unparsed: the program it came from is gone. + pub fn discard(&mut self) { + self.queue.clear(); + self.at = 0; + self.held.clear(); + (self.held_since, self.frame_end, self.frame_started) = (None, None, None); + } + /// Rewraps the text to the new width, the way a terminal window does. pub fn resize(&mut self, cols: u16, rows: u16) { self.term.resize(Size(cols.max(MIN_COLUMNS as u16), rows.max(1))); @@ -568,15 +833,19 @@ impl Term { } /// Mouse reporting off, as `reset` would turn it: for a program that left it on. + /// While an update is held these bytes join it, keeping their place in the stream. pub fn mouse_off(&mut self) { - self.advance(b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l"); + debug_assert!(self.queue[self.at..].is_empty(), "nerd's bytes go after the program's"); + self.replay(MOUSE_OFF); } - /// Input and screen modes as a fresh terminal has them, text kept: for the next program in a pane. + /// Input and screen modes as a fresh terminal has them, text kept: for the next program in a pane, once the + /// last one is killed. Its unparsed output goes; the reset begins with ESC, where vte and the filter agree again. /// The scroll region is reset between a cursor save and restore: `CSI r` moves the cursor home. pub fn modes_off(&mut self) { + self.discard(); self.mouse_off(); - self.advance(b"\x1b[?1049l\x1b7\x1b[r\x1b8\x1b[?1l\x1b>\x1b[?2004l\x1b[?1004l\x1b[?6l\x1b[4l\x1b(B\x1b[?25h\x1b[?7h\x1b[m"); + self.replay(b"\x1b[?1049l\x1b7\x1b[r\x1b8\x1b[?1l\x1b>\x1b[?2004l\x1b[?1004l\x1b[?6l\x1b[4l\x1b(B\x1b[?25h\x1b[?7h\x1b[m"); self.theme_notify = false; self.pointer = None; } diff --git a/src/term/bounds.rs b/src/term/bounds.rs new file mode 100644 index 0000000..8445627 --- /dev/null +++ b/src/term/bounds.rs @@ -0,0 +1,473 @@ +//! What a pane's output may make the emulator hold or do: a byte filter in front of vte for the caps that are byte +//! properties, and a Handler in front of alacritty's Term for the ones that are emulator state. + +use alacritty_terminal::event::EventListener; +use alacritty_terminal::index::{Column, Point}; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::vte::ansi::{ + self, Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes, KeyboardModesApplyBehavior, LineClearMode, + Mode, ModifyOtherKeys, PrivateMode, Rgb, ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode, cursor_icon::CursorIcon, +}; + +/// GNOME VTE's cap on an OSC string, in code points; the OSC number and its `;` count. +const OSC_CHARS: u16 = 4096; +/// The same in bytes, for runs that are not UTF-8: 4096 four-byte characters. +const OSC_BYTES: u16 = 16384; +// ceiling: 30 marks per cell (UAX #15 Stream-Safe); a pane full of marked cells still holds ~10× a plain one, bounded by Scrollback +pub const ZERO_WIDTH: usize = 30; +/// GNOME VTE's title stack depth; the oldest goes when it is full. +const TITLES: usize = 8; +/// vte's own private mode number for a synchronized update. +const SYNC: u16 = 2026; + +pub const HIT_OSC: u8 = 1; +pub const HIT_REP: u8 = 2; +pub const HIT_MARKS: u8 = 4; +pub const HIT_TITLES: u8 = 8; + +/// The caps that fired, as the log names them. +pub fn describe(hits: u8) -> String { + [ + (HIT_OSC, "an OSC over 4096 characters"), + (HIT_REP, "a repeat wider than the pane"), + (HIT_MARKS, "a character with over 30 combining marks"), + (HIT_TITLES, "over 8 saved titles"), + ] + .iter() + .filter(|(bit, _)| hits & bit != 0) + .map(|(_, what)| *what) + .collect::>() + .join(", ") +} + +/// A CSI sequence as far as the caps need it: REP's count and whether it names mode 2026. +#[derive(Clone, Copy, Default)] +struct Csi { + first: u16, + first_done: bool, + seps: u8, + marker: u8, + /// No byte vte collects has come yet: the next 0x3C–0x3F is the marker. + entry: bool, + /// An intermediate, or vte's CsiIgnore: neither REP nor a mode. + other: bool, + cur: u16, + sub: bool, + named: bool, +} + +#[derive(Clone, Copy, Default)] +enum State { + /// Ground, DCS, SOS/PM/APC and EscapeIntermediate: only ESC matters there. + #[default] + Other, + Esc, + Csi(Csi), + Osc { cps: u16, bytes: u16 }, + OscCut, +} + +/// Mirrors the part of vte 0.15's state machine the caps need; every byte passes except where a cap cuts. +#[derive(Default)] +pub struct Filter { + state: State, +} + +impl Filter { + /// Filters `bytes` into `out`; `marks` gets the offsets in `out` right after each begin (true) or end of a + /// synchronized update, as vte dispatches them. + pub fn pass(&mut self, bytes: &[u8], cols: u16, hits: &mut u8, out: &mut Vec, marks: &mut Vec<(usize, bool)>) { + // Bytes pass in runs: `out` is written only where a cap cuts, from `from` on. + let (mut i, mut from) = (0, 0); + while i < bytes.len() { + let rest = &bytes[i..]; + match &mut self.state { + State::Other => { + let n = find_esc(rest); + i += n; + if n == rest.len() { + continue; + } + i += 1; + // The common case at once: a whole CSI whose final needs nothing (SGR, cursor moves, erases). + if let [b'[', tail @ ..] = &bytes[i..] + && let Some(m) = tail.iter().position(|&b| matches!(b, 0x18 | 0x1a | 0x1b | 0x40..=0x7e)) + && matches!(tail[m], 0x40..=0x7e) + && !matches!(tail[m], b'b' | b'h' | b'l') + { + i += 2 + m; + continue; + } + self.state = State::Esc; + } + State::Esc => { + i += 1; + self.state = match rest[0] { + 0x18 | 0x1a => State::Other, + 0x00..=0x1f | 0x7f..=0xff => State::Esc, + b'[' => State::Csi(Csi { entry: true, ..Default::default() }), + b']' => State::Osc { cps: 0, bytes: 0 }, + _ => State::Other, + }; + } + State::Csi(c) => { + let Some(n) = rest.iter().position(|&b| matches!(b, 0x18 | 0x1a | 0x1b | 0x40..=0x7e)) else { + rest.iter().for_each(|&b| c.step(b)); + break; + }; + i += n + 1; + self.state = match rest[n] { + 0x1b => State::Esc, + // Only these finals need the parameters, so only they are stepped through. + f @ (b'b' | b'h' | b'l') => { + let mut c = *c; + rest[..n].iter().for_each(|&b| c.step(b)); + match c.end(f, cols) { + End::Rep => { + *hits |= HIT_REP; + out.extend_from_slice(&bytes[from..i - 1]); + out.push(0x18); + out.extend_from_slice(format!("\x1b[{cols}b").as_bytes()); + from = i; + } + End::Mark(begin) => marks.push((out.len() + i - from, begin)), + End::Pass => {} + } + State::Other + } + _ => State::Other, + }; + } + State::Osc { cps, bytes: n } => { + let end = rest.iter().position(|&b| matches!(b, 0x07 | 0x18 | 0x1a | 0x1b)).unwrap_or(rest.len()); + let mut k = 0; + while k < end { + let starts = !(0x80..=0xbf).contains(&rest[k]); + if (starts && *cps >= OSC_CHARS) || *n >= OSC_BYTES { + break; + } + *cps += starts as u16; + *n += 1; + k += 1; + } + i += k; + if k < end { + *hits |= HIT_OSC; + out.extend_from_slice(&bytes[from..i]); + from = i; + self.state = State::OscCut; + } else if end < rest.len() { + i += 1; + self.state = after_osc(rest[end]); + } + } + State::OscCut => { + let Some(end) = rest.iter().position(|&b| matches!(b, 0x07 | 0x18 | 0x1a | 0x1b)) else { + i = bytes.len(); + from = i; + break; + }; + i += end; + from = i; + i += 1; + self.state = after_osc(rest[end]); + } + } + } + out.extend_from_slice(&bytes[from..]); + } +} + +/// How many continuation bytes the character cut at the end of `slice` still needs, `before` being the last slice's. +pub fn pending(slice: &[u8], before: u8) -> u8 { + for (after, &b) in slice.iter().rev().take(4).enumerate() { + let need = match b { + 0x80..=0xbf => continue, + 0xc2..=0xdf => 1, + 0xe0..=0xef => 2, + 0xf0..=0xf4 => 3, + _ => return 0, + }; + return (need as usize).saturating_sub(after) as u8; + } + if slice.len() < 4 { before.saturating_sub(slice.len() as u8) } else { 0 } +} + +/// After an OSC's terminator: ESC begins the next sequence. +fn after_osc(b: u8) -> State { + if b == 0x1b { State::Esc } else { State::Other } +} + +/// What a CSI's final byte asks of the filter. +enum End { + Pass, + /// A REP wider than the pane: CAN in place of its final, then one of the pane's width. + Rep, + /// A mode naming 2026: true for a begin. + Mark(bool), +} + +/// The first ESC, eight bytes at a time. +fn find_esc(b: &[u8]) -> usize { + let mut i = 0; + while let Some(w) = b.get(i..i + 8) { + let x = u64::from_le_bytes(w.try_into().unwrap()) ^ 0x1b1b_1b1b_1b1b_1b1b; + if x.wrapping_sub(0x0101_0101_0101_0101) & !x & 0x8080_8080_8080_8080 != 0 { + break; + } + i += 8; + } + i + b[i..].iter().position(|&x| x == 0x1b).unwrap_or(b.len() - i) +} + +impl Csi { + /// A byte inside the sequence: not CAN, SUB, ESC or a final. + fn step(&mut self, b: u8) { + if self.other { + return; + } + match b { + 0x20..=0x2f => self.other = true, + b'0'..=b'9' => { + self.entry = false; + let d = (b - b'0') as u16; + if !self.first_done { + self.first = self.first.saturating_mul(10).saturating_add(d); + } + if !self.sub { + self.cur = self.cur.saturating_mul(10).saturating_add(d); + } + } + b':' | b';' => { + self.entry = false; + self.first_done = true; + self.seps = self.seps.saturating_add(1); + if self.cur == SYNC { + self.named = true; + } + if b == b';' { + (self.cur, self.sub) = (0, false); + } else { + self.sub = true; + } + } + 0x3c..=0x3f if self.entry => { + self.marker = b; + self.entry = false; + } + 0x3c..=0x3f => self.other = true, + // Executed or ignored by vte without leaving the sequence. + _ => {} + } + } + + /// The final byte, as vte dispatches it: REP with a count, or a private mode. + fn end(mut self, f: u8, cols: u16) -> End { + if self.cur == SYNC && !self.other { + self.named = true; + } + let plain = !self.other && self.seps < 32; + if f == b'b' && plain && self.marker == 0 && self.first.max(1) > cols { + End::Rep + } else if matches!(f, b'h' | b'l') && plain && self.marker == b'?' && self.named { + End::Mark(f == b'h') + } else { + End::Pass + } + } +} + +/// The title stack nerd keeps in place of alacritty's 4096-deep one. +#[derive(Default)] +pub struct Titles { + stack: Vec>, + now: Option, +} + +#[cfg(test)] +impl Titles { + pub fn depth(&self) -> usize { + self.stack.len() + } +} + +/// vte never buffers a synchronized update: Term holds it, in slices it can bound +#[derive(Default)] +pub struct NoSync; + +impl ansi::Timeout for NoSync { + fn set_timeout(&mut self, _: std::time::Duration) {} + fn clear_timeout(&mut self) {} + fn pending_timeout(&self) -> bool { + false + } +} + +/// alacritty's Term behind the caps that are its state: marks per cell, the title stack, links, back tabs. +pub struct Guard<'a, T> { + pub term: &'a mut alacritty_terminal::Term, + pub titles: &'a mut Titles, + pub hits: &'a mut u8, +} + +#[cfg(test)] +thread_local! { + /// How many back-tab passes alacritty was asked for, so a test can count them. + pub static BACK_TABS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +macro_rules! guard { + ({ $($own:tt)* } $($name:ident($($arg:ident: $ty:ty),*);)*) => { + impl Handler for Guard<'_, T> { + $($own)* + $(#[inline] fn $name(&mut self, $($arg: $ty),*) { Handler::$name(self.term, $($arg),*) })* + } + #[cfg(test)] + pub const FORWARDED: &[&str] = &[$(stringify!($name)),*]; + }; +} + +guard! { + { + #[inline] + fn input(&mut self, c: char) { + if c.is_ascii() { + return self.term.input(c); + } + // The cell a zero-width char would join, found as alacritty's input finds it. + let g = self.term.grid(); + let mut at = g.cursor.point; + if !g.cursor.input_needs_wrap { + at.column = Column(at.column.saturating_sub(1)); + } + if g[at].flags.contains(Flags::WIDE_CHAR_SPACER) { + at.column = Column(at.column.saturating_sub(1)); + } + let marks = |t: &alacritty_terminal::Term, p: Point| t.grid()[p].zerowidth().map_or(0, <[char]>::len); + if marks(self.term, at) < ZERO_WIDTH { + return self.term.input(c); + } + // alacritty's own width table: a zero-width char here would only be undone below, at a deep copy. + if unicode_width::UnicodeWidthChar::width(c) == Some(0) { + *self.hits |= HIT_MARKS; + return; + } + let kept = g[at].clone(); + self.term.input(c); + if marks(self.term, at) > ZERO_WIDTH { + self.term.grid_mut()[at] = kept; + *self.hits |= HIT_MARKS; + } + } + + fn move_backward_tabs(&mut self, count: u16) { + for _ in 0..count { + let before = self.term.grid().cursor.point.column; + #[cfg(test)] + BACK_TABS.with(|n| n.set(n.get() + 1)); + self.term.move_backward_tabs(1); + if self.term.grid().cursor.point.column == before { + break; + } + } + } + + fn set_title(&mut self, title: Option) { + self.titles.now = title.clone(); + self.term.set_title(title); + } + + fn push_title(&mut self) { + if self.titles.stack.len() >= TITLES { + self.titles.stack.remove(0); + *self.hits |= HIT_TITLES; + } + self.titles.stack.push(self.titles.now.clone()); + } + + fn pop_title(&mut self) { + if let Some(t) = self.titles.stack.pop() { + self.set_title(t); + } + } + + fn reset_state(&mut self) { + *self.titles = Titles::default(); + self.term.reset_state(); + } + + // nerd shows no links: a link kept per cell would keep its URI per cell. + fn set_hyperlink(&mut self, _: Option) {} + } + set_cursor_style(s: Option); + set_cursor_shape(s: CursorShape); + goto(line: i32, col: usize); + goto_line(line: i32); + goto_col(col: usize); + insert_blank(n: usize); + move_up(n: usize); + move_down(n: usize); + identify_terminal(i: Option); + device_status(n: usize); + move_forward(n: usize); + move_backward(n: usize); + move_down_and_cr(n: usize); + move_up_and_cr(n: usize); + put_tab(n: u16); + backspace(); + carriage_return(); + linefeed(); + bell(); + substitute(); + newline(); + set_horizontal_tabstop(); + scroll_up(n: usize); + scroll_down(n: usize); + insert_blank_lines(n: usize); + delete_lines(n: usize); + erase_chars(n: usize); + delete_chars(n: usize); + move_forward_tabs(n: u16); + save_cursor_position(); + restore_cursor_position(); + clear_line(m: LineClearMode); + clear_screen(m: ClearMode); + clear_tabs(m: TabulationClearMode); + set_tabs(n: u16); + reverse_index(); + terminal_attribute(a: Attr); + set_mode(m: Mode); + unset_mode(m: Mode); + report_mode(m: Mode); + set_private_mode(m: PrivateMode); + unset_private_mode(m: PrivateMode); + report_private_mode(m: PrivateMode); + set_scrolling_region(top: usize, bottom: Option); + set_keypad_application_mode(); + unset_keypad_application_mode(); + set_active_charset(i: CharsetIndex); + configure_charset(i: CharsetIndex, c: StandardCharset); + set_color(i: usize, c: Rgb); + dynamic_color_sequence(p: String, i: usize, t: &str); + reset_color(i: usize); + clipboard_store(c: u8, b: &[u8]); + clipboard_load(c: u8, t: &str); + decaln(); + text_area_size_pixels(); + text_area_size_chars(); + set_mouse_cursor_icon(i: CursorIcon); + report_keyboard_mode(); + push_keyboard_mode(m: KeyboardModes); + pop_keyboard_modes(n: u16); + set_keyboard_mode(m: KeyboardModes, b: KeyboardModesApplyBehavior); + set_modify_other_keys(m: ModifyOtherKeys); + report_modify_other_keys(); + set_scp(p: ScpCharPath, u: ScpUpdateMode); +} + +/// The methods Guard answers itself rather than forwarding. +#[cfg(test)] +pub const OWN: &[&str] = &["input", "move_backward_tabs", "set_title", "push_title", "pop_title", "reset_state", "set_hyperlink"]; + +#[cfg(test)] +pub(crate) mod tests; diff --git a/src/term/bounds/tests.rs b/src/term/bounds/tests.rs new file mode 100644 index 0000000..3f3074c --- /dev/null +++ b/src/term/bounds/tests.rs @@ -0,0 +1,763 @@ +use super::*; +use crate::term::{SLICE, Term}; +use alacritty_terminal::grid::Dimensions; + +/// Seeded xorshift: every run sees the same streams. +pub(crate) struct Rng(u64); + +impl Rng { + pub(crate) fn new(seed: u64) -> Self { + Self(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1) + } + pub(crate) fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + pub(crate) fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +/// Output shaped like what panes print: a shell, vim, an agent's synchronized frames, ncurses REP, a pager; +/// then every recorded file under tests/fixtures/terminal/. +pub(crate) fn corpus() -> Vec<(String, Vec)> { + let mut out = vec![ + ("shell".to_string(), shell()), + ("vim".to_string(), vim()), + ("agent".to_string(), agent(120)), + ("agent-wide".to_string(), agent(600)), + ("rep".to_string(), rep()), + ("pager".to_string(), pager()), + ]; + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/terminal"); + if let Ok(entries) = std::fs::read_dir(&dir) { + let mut files: Vec<_> = entries.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "bin")).collect(); + files.sort(); + for p in files { + out.push((p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read(&p).unwrap())); + } + } + out +} + +fn shell() -> Vec { + let mut s = String::new(); + for i in 0..200 { + s += "\x1b[1;32muser@host\x1b[0m:\x1b[34m~/project\x1b[0m$ ls -G\r\n"; + s += &format!("\x1b[1;34mdir{i}\x1b[39;49m\x1b[0m\t\x1b[31mexec{i}\x1b[39;49m\x1b[0m\tplain{i}.txt\t\x1b[35mlink\x1b[39;49m\x1b[0m\r\n"); + s += &format!("* \x1b[33mcommit {i:040x}\x1b[m\r\n|\\ \r\n| * \x1b[1;31m(branch)\x1b[m message é 中文 {i}\r\n"); + s += "\x1b]0;user@host: ~/project\x07"; + } + s.into_bytes() +} + +fn vim() -> Vec { + let mut s = String::from("\x1b[22;2t\x1b[22;1t\x1b]2;file.rs - VIM\x07\x1b[?1049h\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2J\x1b[1;39r"); + for frame in 0..30 { + s += "\x1b[?25l"; + for row in 1..=38 { + s += &format!("\x1b[{row};1H\x1b[33m{:>3} \x1b[m\x1b[38;5;{}mfn line_{frame}_{row}() {{ é }}\x1b[K", row, (row * 7) % 256); + } + s += &format!("\x1b[39;1H\x1b[7m-- INSERT -- {frame}\x1b[m\x1b[1;1H\x1bM\x1b[2;3H\x1b[?25h"); + } + s += "\x1b[39;1H\x1b[K\x1b[r\x1b[?1l\x1b>\x1b[?1049l\x1b[23;0;0t\x1b[23;2t\x1b[23;1t"; + s.into_bytes() +} + +fn agent(cols: usize) -> Vec { + let mut s = String::from("\x1b[?2004h\x1b[?1004h\x1b]0;\u{2733} Claude Code\x07"); + for frame in 0..60 { + s += "\x1b[?2026h\x1b[?25l"; + if frame > 0 { + s += "\x1b[12A"; + } + for row in 0..12 { + let text = format!("\u{25cf} step {frame}.{row} \u{1f600} 中 a\u{301}\u{302} \x1b]8;;https://example.com/{row}\x07link\x1b]8;;\x07"); + s += &format!("\x1b[2K\x1b[38;2;{};{};200m{text}\x1b[39m{}\r\n", row * 20, frame * 4, " ".repeat(cols.saturating_sub(60) % 97)); + } + s += "\x1b[?25h\x1b[6n\x1b[?2026l"; + s += &format!("\x1b]0;\u{2802} Working {frame}\x07"); + } + s.into_bytes() +} + +fn rep() -> Vec { + let mut s = String::new(); + for i in 1..60 { + s += &format!("x\x1b[{}b\r\n=\x1b[{}b|\r\n", i, 79 - i); + } + s.into_bytes() +} + +fn pager() -> Vec { + let mut s = String::from("\x1b[?1049h\x1b[H\x1b[2J\x1b[3g"); + for c in (1..80).step_by(8) { + s += &format!("\x1b[1;{c}H\x1bH"); + } + for i in 0..300 { + s += &format!("\x1b[24;1H\x1b[S\x1b[23;1Ha\tb\tc {i}\x1b[7m:\x1b[27m\x1b[2T\x1b[3S\x1b[1;1H\x1b[2L\x1b[3M\x1b[4X\x1b[5P\x1b[3@\x1b[2Z\x1b[3I"); + } + s += "\x1b[?1049l"; + s.into_bytes() +} + +#[derive(Debug, PartialEq, Clone)] +enum Call { + Print(char), + Exec(u8), + Hook(Vec>, Vec, bool, char), + Put(u8), + Unhook, + Osc(Vec>, bool), + Csi(Vec>, Vec, bool, char), + Esc(Vec, bool, u8), +} + +/// vte's own parser, recording each call with the offset of the byte that made it. +#[derive(Default)] +struct Rec { + calls: Vec<(usize, Call)>, + at: usize, +} + +impl alacritty_terminal::vte::Perform for Rec { + fn print(&mut self, c: char) { + self.calls.push((self.at, Call::Print(c))); + } + fn execute(&mut self, b: u8) { + self.calls.push((self.at, Call::Exec(b))); + } + fn hook(&mut self, p: &alacritty_terminal::vte::Params, i: &[u8], ignore: bool, a: char) { + self.calls.push((self.at, Call::Hook(p.iter().map(<[u16]>::to_vec).collect(), i.to_vec(), ignore, a))); + } + fn put(&mut self, b: u8) { + self.calls.push((self.at, Call::Put(b))); + } + fn unhook(&mut self) { + self.calls.push((self.at, Call::Unhook)); + } + fn osc_dispatch(&mut self, p: &[&[u8]], bell: bool) { + self.calls.push((self.at, Call::Osc(p.iter().map(|x| x.to_vec()).collect(), bell))); + } + fn csi_dispatch(&mut self, p: &alacritty_terminal::vte::Params, i: &[u8], ignore: bool, a: char) { + self.calls.push((self.at, Call::Csi(p.iter().map(<[u16]>::to_vec).collect(), i.to_vec(), ignore, a))); + } + fn esc_dispatch(&mut self, i: &[u8], ignore: bool, b: u8) { + self.calls.push((self.at, Call::Esc(i.to_vec(), ignore, b))); + } +} + +/// vte's calls for `bytes`, fed one byte at a time. +fn oracle(bytes: &[u8]) -> Vec<(usize, Call)> { + let mut parser = alacritty_terminal::vte::Parser::new(); + let mut rec = Rec::default(); + for (i, b) in bytes.iter().enumerate() { + rec.at = i + 1; + parser.advance(&mut rec, std::slice::from_ref(b)); + } + rec.calls +} + +fn filter(bytes: &[u8], cols: u16) -> (Vec, Vec<(usize, bool)>, u8) { + let (mut out, mut marks, mut hits) = (Vec::new(), Vec::new(), 0); + Filter::default().pass(bytes, cols, &mut hits, &mut out, &mut marks); + (out, marks, hits) +} + +/// What the caps promise of vte's calls on the filter's output. +fn assert_bounded(raw: &[u8], cols: u16, name: &str) { + let (out, marks, _) = filter(raw, cols); + let calls = oracle(&out); + let mut want = Vec::new(); + for (at, c) in &calls { + match c { + Call::Osc(p, _) => assert!(p.iter().map(Vec::len).sum::() <= OSC_BYTES as usize, "{name}: an OSC of {} bytes", p.iter().map(Vec::len).sum::()), + Call::Csi(p, i, false, 'b') if i.is_empty() => { + let n = p.first().and_then(|x| x.first()).copied().unwrap_or(0).max(1); + assert!(n <= cols, "{name}: REP {n} at {cols} columns"); + } + Call::Csi(p, i, false, a @ ('h' | 'l')) if i == b"?" && p.iter().any(|x| x[0] == SYNC) => want.push((*at, *a == 'h')), + _ => {} + } + } + assert_eq!(marks, want, "{name}: the marks are vte's own begins and ends"); +} + +const ALPHABET: &[&[u8]] = &[ + b"\x1b", b"[", b"]", b"P", b"_", b"X", b"#", b"8", b"\\", b"\x07", b"\x18", b"\x1a", b"\x9c", b"\x7f", b"\x80", b"\xbf", b"\xc3", + b"\xa9", b"\xf0", b";", b":", b"?", b">", b" ", b"0", b"1", b"5", b"9", b"2026", b"b", b"Z", b"h", b"l", b"t", b"a", b"\n", +]; + +/// A stream of the alphabet with, unless `tame`, the long and over-cap pieces mixed in. +fn stream(rng: &mut Rng, tame: bool) -> Vec { + let mut s = Vec::new(); + for _ in 0..rng.below(400) { + let pick = rng.below(if tame { 110 } else { 120 }); + match pick { + 0..100 => { + let t = ALPHABET[rng.below(ALPHABET.len())]; + if !(tame && t == b"b") { + s.extend_from_slice(t); + } + } + 100 => s.extend_from_slice(b"\x1b[?2026h"), + 101 => s.extend_from_slice(b"\x1b[?2026l"), + 102 => s.extend_from_slice(b"\x1b[?1049;2026h"), + 103 => s.extend_from_slice(format!("\x1b[{}b", 1 + rng.below(80)).as_bytes()), + 104 => s.extend_from_slice(b"\x1b]0;title\x07"), + 105 => s.extend_from_slice("é中\u{301}".as_bytes()), + 106 => s.extend_from_slice(b"\x1b[?2026:1h"), + 107 => s.extend_from_slice(b"\x1b[>2026h"), + 108 => s.extend_from_slice(b"\x1b[\x05?2026l"), + 109 => s.extend_from_slice(&[b';'; 33][..rng.below(34)]), + 110 => s.extend_from_slice(format!("\x1b[{}b", 81 + rng.below(70000)).as_bytes()), + 111 => s.extend(std::iter::repeat_n(b'7', rng.below(40))), + 112 => s.extend("\x1b]0;".bytes().chain("é".repeat(4000 + rng.below(200)).bytes())), + 113 => s.extend(b"\x1b]".iter().copied().chain(std::iter::repeat_n(0x80, 16300 + rng.below(200)))), + 114 => s.extend(b"\x1b]".iter().copied().chain(std::iter::repeat_n(0x9c, 16300 + rng.below(200)))), + 115 => s.extend(b"\x1b]0;\xf0".iter().copied().chain(std::iter::repeat_n(0xbf, 17000))), + 116 => s.extend("\x1b]2;".bytes().chain("\u{1f600}".repeat(4090 + rng.below(10)).bytes())), + 117 => s.extend_from_slice(b"a\x1b[65535b"), + 118 => s.extend_from_slice(b"\x1b[0065535;9b"), + _ => s.extend_from_slice(b"\x1b[3g\x1b[80G\x1b[65535Z"), + } + } + s.push(0x07); + s +} + +#[test] +fn tripwire_the_bounds_mirror_vte_0_15_and_alacritty_0_26() { + let lock = include_str!("../../../Cargo.lock"); + let why = "Filter mirrors vte 0.15, NoSync relies on its Processor never buffering without a pending timeout, and Guard forwards its 71 Handler methods and keeps alacritty's title stack empty (unsafe with kitty_keyboard, alacritty term/mod.rs:1296): re-check all four, then update this test"; + assert!(lock.contains("name = \"vte\"\nversion = \"0.15.0\"") && lock.matches("name = \"vte\"\n").count() == 1, "{why}"); + assert!(lock.contains("name = \"alacritty_terminal\"\nversion = \"0.26.0\"") && lock.matches("name = \"alacritty_terminal\"\n").count() == 1, "{why}"); + assert!(lock.contains("name = \"unicode-width\"\nversion = \"0.2.2\"") && lock.matches("name = \"unicode-width\"\n").count() == 1, "Guard counts zero-width chars with alacritty's own width table: {why}"); + assert!(!alacritty_terminal::term::Config::default().kitty_keyboard, "{why}"); +} + +#[test] +fn the_guard_answers_every_handler_method_of_vte_0_15() { + // vte 0.15 ansi.rs:495-732, in order. + const HANDLER: &[&str] = &[ + "set_title", "set_cursor_style", "set_cursor_shape", "input", "goto", "goto_line", "goto_col", "insert_blank", "move_up", "move_down", + "identify_terminal", "device_status", "move_forward", "move_backward", "move_down_and_cr", "move_up_and_cr", "put_tab", "backspace", + "carriage_return", "linefeed", "bell", "substitute", "newline", "set_horizontal_tabstop", "scroll_up", "scroll_down", + "insert_blank_lines", "delete_lines", "erase_chars", "delete_chars", "move_backward_tabs", "move_forward_tabs", + "save_cursor_position", "restore_cursor_position", "clear_line", "clear_screen", "clear_tabs", "set_tabs", "reset_state", + "reverse_index", "terminal_attribute", "set_mode", "unset_mode", "report_mode", "set_private_mode", "unset_private_mode", + "report_private_mode", "set_scrolling_region", "set_keypad_application_mode", "unset_keypad_application_mode", + "set_active_charset", "configure_charset", "set_color", "dynamic_color_sequence", "reset_color", "clipboard_store", + "clipboard_load", "decaln", "push_title", "pop_title", "text_area_size_pixels", "text_area_size_chars", "set_hyperlink", + "set_mouse_cursor_icon", "report_keyboard_mode", "push_keyboard_mode", "pop_keyboard_modes", "set_keyboard_mode", + "set_modify_other_keys", "report_modify_other_keys", "set_scp", + ]; + assert_eq!(HANDLER.len(), 71); + let mut ours: Vec<&str> = FORWARDED.iter().chain(OWN).copied().collect(); + let mut theirs = HANDLER.to_vec(); + ours.sort_unstable(); + theirs.sort_unstable(); + assert_eq!(ours, theirs); +} + +#[test] +fn vte_dispatches_no_osc_or_repeat_past_the_caps_and_the_marks_are_its_own() { + let mut rng = Rng::new(7); + for i in 0..2000 { + let raw = stream(&mut rng, false); + assert_bounded(&raw, 80, &format!("stream {i}")); + } + for (name, raw) in corpus() { + assert_bounded(&raw, 80, &name); + } +} + +#[test] +fn output_under_the_caps_reaches_vte_unchanged() { + let mut rng = Rng::new(11); + for i in 0..2000 { + let raw = stream(&mut rng, true); + let (out, _, hits) = filter(&raw, 80); + assert_eq!(hits, 0, "stream {i}"); + assert_eq!(oracle(&out), oracle(&raw), "stream {i}"); + } + for (name, raw) in corpus() { + let (out, _, hits) = filter(&raw, 80); + assert_eq!((hits, out == raw), (0, true), "{name}"); + } +} + +#[test] +fn a_stream_split_anywhere_filters_as_it_does_whole() { + let mut rng = Rng::new(3); + let mut raw = Vec::new(); + while raw.len() < 2048 { + raw.extend(stream(&mut rng, false).into_iter().take(300)); + } + raw.truncate(2048); + let whole = filter(&raw, 80); + for at in 0..=raw.len() { + let (mut out, mut marks, mut hits) = (Vec::new(), Vec::new(), 0); + let mut f = Filter::default(); + f.pass(&raw[..at], 80, &mut hits, &mut out, &mut marks); + f.pass(&raw[at..], 80, &mut hits, &mut out, &mut marks); + assert_eq!((out, marks, hits), whole, "split at {at}"); + } +} + +#[test] +fn a_repeat_wider_than_the_pane_is_cut_to_its_width() { + for v in [&b"x\x1b[65535b"[..], b"\x1b[6\n5535b", b"\x1b[0065535b", b"\x1b[65535:1b", b"\x1b[65535;9b", b"\x1b[999999b"] { + let (out, _, hits) = filter(v, 80); + let mut want = v[..v.len() - 1].to_vec(); + want.extend_from_slice(b"\x18\x1b[80b"); + assert_eq!((String::from_utf8_lossy(&out), hits), (String::from_utf8_lossy(&want), HIT_REP), "{v:?}"); + } + let semis = format!("\x1b[99999{}b", ";".repeat(32)); + for v in [&b"\x1b[?65535b"[..], b"\x1b[ 65535b", b"\x1b[>65535b", semis.as_bytes(), b"\x1b[80b", b"\x1b[0b", b"\x1b[3g\x1b[80G\x1b[65535Z"] { + assert_eq!(filter(v, 80), (v.to_vec(), vec![], 0), "{v:?}"); + } +} + +fn osc_payload(out: &[u8]) -> Vec> { + oracle(out).into_iter().filter_map(|(_, c)| if let Call::Osc(p, _) = c { Some(p) } else { None }).next().unwrap_or_default() +} + +#[test] +fn an_osc_keeps_its_first_4096_characters_and_never_more_than_16_kib() { + for pre in [&b"\x1b\x7f]"[..], b"\x1b\n]", b"\x1b\x1b]", b"\x1b\xc3]"] { + let raw: Vec = pre.iter().copied().chain(b"0;".iter().copied()).chain(std::iter::repeat_n(b'x', 5000)).chain([7]).collect(); + let (out, _, hits) = filter(&raw, 80); + assert_eq!(hits, HIT_OSC, "{pre:?} opens an OSC"); + assert_eq!(osc_payload(&out)[1].len(), 4094, "{pre:?}"); + } + let title: Vec = b"\x1b]0;".iter().copied().chain("é".repeat(1 << 19).into_bytes()).chain([7]).collect(); + let p = osc_payload(&filter(&title, 80).0); + assert_eq!(String::from_utf8(p[1].clone()).unwrap().chars().count(), 4094, "`0;` counts toward the 4096"); + let c1 = b"\x1b]0;a\x9cb\x07"; + assert_eq!(osc_payload(&filter(c1, 80).0), vec![b"0".to_vec(), b"a\x9cb".to_vec()], "0x9C does not end it"); + for body in [vec![0x80; 1 << 20], [vec![0xf0], vec![0x80; 1 << 20]].concat(), vec![0x9c; 1 << 20]] { + for end in [&[][..], &[7]] { + let raw: Vec = b"\x1b]".iter().chain(&body).chain(end).copied().collect(); + let (out, _, hits) = filter(&raw, 80); + assert_eq!(hits, HIT_OSC); + assert!(out.len() <= 2 + OSC_BYTES as usize + 1, "{} bytes passed", out.len()); + if !end.is_empty() { + assert!(osc_payload(&out).iter().map(Vec::len).sum::() <= OSC_BYTES as usize); + } + } + } + let wide: Vec = b"\x1b]".iter().copied().chain("\u{1f600}".repeat(4096).into_bytes()).chain([7]).collect(); + assert_eq!(filter(&wide, 80), (wide.clone(), vec![], 0), "4096 four-byte characters pass whole"); +} + +#[test] +fn a_synchronized_update_is_marked_where_vte_begins_and_ends_it() { + let semis = |n: usize| format!("\x1b[?2026{}h", ";".repeat(n)); + let begins = [b"\x1b[?2026h".to_vec(), b"\x1b[?1049;2026h".to_vec(), b"\x1b[?2026:1h".to_vec(), b"\x1b]0;t\x1b[?2026h".to_vec(), b"\x1b[\x05?2026h".to_vec(), semis(31).into_bytes()]; + for v in begins { + assert_eq!(filter(&v, 80).1, vec![(v.len(), true)], "{:?}", String::from_utf8_lossy(&v)); + } + for v in [&b"\x1b[?2026l"[..], b"\x1b[?1;2026l"] { + assert_eq!(filter(v, 80).1, vec![(v.len(), false)], "{v:?}"); + } + for v in [b"\x1b[2026h".to_vec(), b"\x1b[>2026h".to_vec(), b"\x1b[? 2026h".to_vec(), b"\x1b[?20260h".to_vec(), b"\x1b[?67562h".to_vec(), semis(32).into_bytes(), b"\x1bP?2026h\x1b\\".to_vec()] { + assert_eq!(filter(&v, 80).1, vec![], "{:?}", String::from_utf8_lossy(&v)); + } +} + +fn marks_on(t: &Term, row: i32, col: usize) -> usize { + t.term.grid()[alacritty_terminal::index::Line(row)][Column(col)].zerowidth().map_or(0, <[char]>::len) +} + +#[test] +fn a_cell_keeps_at_most_30_zero_width_characters() { + let m = "\u{301}"; + let cases = [ + (format!("a{}", m.repeat(1000)), 0), + (format!("中{}", m.repeat(1000)), 0), + (format!("\x1b[80Ga{}", m.repeat(1000)), 79), + (format!("a{}", format!("{}\x1b[2G", m.repeat(100)).repeat(10)), 0), + (format!("a{m}\x1b[65535b"), 0), + (format!("a{m}\x1b[m\x1b[65535b"), 0), + ]; + for (bytes, col) in cases { + let mut t = Term::new(80, 5, 10); + t.feed(bytes.as_bytes()); + assert_eq!(marks_on(&t, 0, col), ZERO_WIDTH, "{:?}", bytes.chars().take(8).collect::()); + assert_eq!(t.line(0).unwrap().chars().filter(|c| *c != ' ').count(), 31); + assert_eq!(t.take_hits() & HIT_MARKS, HIT_MARKS); + } +} + +#[test] +fn a_character_cut_between_chunks_loses_nothing_after_it() { + for (chunks, want) in [(&[&b"\xd1"[..], b"\x82 \xd0\xbc"][..], "т м"), (&[&b"\xf0"[..], b"\x9f", b"\x98\x80 x\xd0\xbc"][..], "\u{1f600} xм")] { + let mut t = Term::new(80, 5, 10); + for c in chunks { + t.take(c); + t.parse(None); + } + assert_eq!(t.line(0).unwrap().trim_end(), want); + } +} + +#[test] +fn titles_nest_eight_deep_dropping_the_oldest() { + let mut t = Term::new(80, 5, 10); + for c in 'A'..='T' { + t.feed(format!("\x1b]2;{c}\x07\x1b[22t").as_bytes()); + } + assert_eq!(t.titles.depth(), 8); + let mut seen = Vec::new(); + for _ in 0..20 { + t.feed(b"\x1b[23t"); + seen.push(t.title().unwrap().to_string()); + } + let want: Vec = ('M'..='T').rev().map(String::from).chain(std::iter::repeat_n("M".to_string(), 12)).collect(); + assert_eq!(seen, want); + t.feed(b"\x1b]2;x\x07\x1b[22t\x1b[22t\x1bc"); + assert_eq!(t.titles.depth(), 0, "a reset empties the stack"); + let before = t.title().map(str::to_string); + t.feed(b"\x1b[23t"); + assert_eq!(t.title().map(str::to_string), before, "and a pop after it changes nothing"); +} + +#[test] +fn links_are_not_kept_in_cells() { + let mut t = Term::new(80, 24, 100); + let link = format!("\x1b]8;;{}\x07x", "u".repeat(4096)); + for _ in 0..1000 { + t.feed(link.as_bytes()); + } + let g = t.term.grid(); + for l in -(g.history_size() as i32)..g.screen_lines() as i32 { + for c in 0..g.columns() { + assert!(g[alacritty_terminal::index::Line(l)][Column(c)].hyperlink().is_none()); + } + } +} + +#[test] +fn vims_title_save_and_restore_is_as_before() { + let mut a = Term::new(120, 40, 100); + let mut b = Term::unbounded(120, 40, 100); + for t in [&mut a, &mut b] { + t.feed(b"\x1b]2;shell\x07"); + t.feed(&vim()); + } + assert_eq!(a.title(), b.title()); + assert_eq!(a.title(), Some("shell")); +} + +#[test] +fn a_back_tab_scans_the_row_once() { + let back_tabs = |bytes: &[u8]| { + let (mut a, mut b) = (Term::new(600, 5, 0), Term::unbounded(600, 5, 0)); + BACK_TABS.with(|n| n.set(0)); + a.feed(bytes); + b.feed(bytes); + assert_eq!(a.full_state(), b.full_state(), "{bytes:?}"); + (BACK_TABS.with(|n| n.get()), a.screen().cursor_position().1) + }; + let (passes, _) = back_tabs(b"\x1b[3g\x1b[600G\x1b[65535Z"); + assert!(passes <= 2, "{passes} passes with no stop to the left"); + let (passes, col) = back_tabs(b"\x1b[600G\x1b[65535Z"); + assert!(passes <= 76 && col == 0, "{passes} passes, column {col}"); +} + +/// Where two `full_state`s part: the row and the cell, or the line after the grid. +fn first_difference(a: &str, b: &str) -> String { + for (row, (x, y)) in a.lines().zip(b.lines()).enumerate() { + if x != y { + let cell = x.split('|').zip(y.split('|')).enumerate().find(|(_, (p, q))| p != q); + return format!("row {row}: {cell:?}"); + } + } + "none".into() +} + +/// Both Terms fed `raw`, the bounded one in `pieces` and parsed in slices of `slice`. +fn differ(raw: &[u8], cols: u16, rows: u16, cuts: &[usize], slice: usize) -> (String, String) { + let mut a = Term::new(cols, rows, 1000); + let mut b = Term::unbounded(cols, rows, 1000); + let mut from = 0; + for &to in cuts.iter().chain([&raw.len()]) { + a.take(&raw[from..to]); + while !a.parse_in(None, slice) {} + from = to; + } + b.feed(raw); + a.finish(); + b.finish(); + (a.full_state(), b.full_state()) +} + +#[test] +fn every_corpus_stream_leaves_the_same_state_whole_split_and_sliced() { + for (name, raw) in corpus() { + for (cols, rows) in [(120, 40), (80, 24)] { + let (a, b) = differ(&raw, cols, rows, &[], SLICE); + assert!(a == b, "{name} at {cols}x{rows}, whole"); + let mut rng = Rng::new(raw.len() as u64); + let mut cuts: Vec = (0..64).map(|_| rng.below(raw.len())).collect(); + cuts.sort_unstable(); + let (a, b) = differ(&raw, cols, rows, &cuts, SLICE); + assert!(a == b, "{name} at {cols}x{rows}, split at 64 points"); + for slice in [1, 7] { + let (a, b) = differ(&raw, cols, rows, &[], slice); + assert!(a == b, "{name} at {cols}x{rows}, slices of {slice}: {}", first_difference(&a, &b)); + } + } + } +} + +#[test] +fn each_handler_method_leaves_the_same_state() { + let pre = b"\x1b[1;1Habcdefghij\r\nabcdefghij\r\nabcdefghij\r\n\x1b[2;3H"; + let equal: &[&[u8]] = &[ + b"\x1b]2;t\x07", b"\x1b[5 q", b"\x1b]50;CursorShape=1\x07", "xé中".as_bytes(), b"\x1b[5;7H", b"\x1b[9d", b"\x1b[9G", b"\x1b[1G\x1b[2@", + b"\x1b[2A", b"\x1b[2B", b"\x1b[c", b"\x1b[6n", b"\x1b[3C", b"\x1b[2D", b"\x1b[2E", b"\x1b[1F", b"\t", b"\x08", b"\r", b"\n", b"\x07", + b"\x1a", b"\x1b[5G\x1bH", b"\x1b[2S", b"\x1b[2T", b"\x1b[2L", b"\x1b[2M", b"\x1b[3X", b"\x1b[3P", b"\x1b[9G\x1b[2Z", + b"\x1b[3g\x1b[9G\x1b[65535Z", b"\x1b[9G\x1b[65535Z", b"\x1b[2I", b"\x1b7", b"\x1b7\x1b[5;5H\x1b8", b"\x1b[2K", b"\x1b[2J", b"\x1b[3J", + b"\x1b[3g\t", b"\x1b[?5W", b"\x1b]2;t\x07\x1b[22t\x1bc", b"\x1b[1;1H\x1bM", b"\x1b[1;3;31;48;5;200;58:2::1:2:3mx", b"\x1b[4hx", + b"\x1b[4h\x1b[4lx", b"\x1b[4$p", b"\x1b[?1000h\x1b[?1006h", b"\x1b[?2026hx\x1b[?2026l", b"\x1b[?25l", b"\x1b[?2026l", + b"\x1b[?1049$p", b"\x1b[?2026$p", b"\x1b[2;3r", b"\x1b=", b"\x1b=\x1b>", b"\x1b)0\x0eq\x0fq", b"\x1b(0q", b"\x1b]4;1;rgb:12/34/56\x07", + b"\x1b]11;?\x07", b"\x1b]4;1;rgb:12/34/56\x07\x1b]104;1\x07", b"\x1b]52;c;aGk=\x07", b"\x1b]52;c;?\x07", b"\x1b#8", + b"\x1b]2;a\x07\x1b[22t", b"\x1b]2;a\x07\x1b[22t\x1b]2;b\x07\x1b[23t", b"\x1b[14t", b"\x1b[18t", b"\x1b]22;pointer\x07", b"\x1b[?u", + b"\x1b[>1u", b"\x1b[4;2m", b"\x1b[?4m", b"\x1b[0;0 k", + ]; + let run = |bytes: &[u8]| { + let (mut a, mut b) = (Term::new(80, 24, 100), Term::unbounded(80, 24, 100)); + for t in [&mut a, &mut b] { + t.feed(pre); + t.feed(bytes); + t.finish(); + } + (a, b) + }; + for bytes in equal { + let (a, b) = run(bytes); + assert!(a.full_state() == b.full_state(), "{:?}", String::from_utf8_lossy(bytes)); + } + // newline: vte 0.15 never calls it, so it is called on both directly. + let (mut a, mut b) = run(b""); + Guard { term: &mut a.term, titles: &mut a.titles, hits: &mut a.hits }.newline(); + b.term.newline(); + assert!(a.full_state() == b.full_state(), "newline"); + // The three that differ, by design. + let (a, b) = run(format!("a{}", "\u{301}".repeat(31)).as_bytes()); + assert_eq!((marks_on(&a, 1, 2), marks_on(&b, 1, 2)), (30, 31)); + let pushes: String = (1..=9).map(|i| format!("\x1b]2;{i}\x07\x1b[22t")).collect(); + let (a, b) = run(format!("{pushes}{}", "\x1b[23t".repeat(9)).as_bytes()); + assert_eq!((a.titles.depth(), a.title(), b.title()), (0, Some("2"), Some("1")), "8 kept against 9"); + let (a, b) = run(b"\x1b]8;;http://x\x07y"); + let link = |t: &Term| t.term.grid()[alacritty_terminal::index::Line(1)][Column(2)].hyperlink().is_some(); + assert_eq!((link(&a), link(&b)), (false, true)); +} + +#[test] +fn a_dump_from_an_older_build_is_capped_as_it_is_restored() { + let mut t = Term::new(80, 24, 100); + t.restore(format!("a{}\r\nnext", "\u{301}".repeat(5000)).as_bytes(), 0); + assert_eq!(t.line(0).unwrap().trim_end().chars().count(), 31); + assert!(t.line(1).unwrap().starts_with("next")); + let once = t.dump(); + let mut u = Term::new(80, 24, 100); + u.restore(&once, 0); + assert_eq!(u.dump(), once); +} + +#[test] +fn a_huge_title_saved_many_times_stays_small() { + let mut t = Term::new(80, 24, 100); + t.feed(&[&b"\x1b]0;"[..], &vec![b'x'; 1 << 20], b"\x07"].concat()); + for _ in 0..64 { + t.feed(b"\x1b[22t"); + } + assert!(t.title().unwrap().len() <= OSC_BYTES as usize); + assert!(t.titles.depth() <= TITLES); +} + +#[test] +fn an_update_holds_at_most_2_mib_and_vte_never_buffers() { + let mut t = Term::new(80, 24, 30_000); + t.take(b"\x1b[?2026h"); + let total = (2 << 20) + (16 << 10); + for _ in 0..total / 8192 { + t.take(&[b'x'; 8192]); + assert!(t.held.len() <= crate::term::HOLD_CAP); + while !t.parse(None) {} + assert_eq!(t.parser.sync_bytes_count(), 0); + } + t.finish(); + let xs: usize = t.all_lines().iter().map(|l| l.matches('x').count()).sum(); + assert_eq!(xs, total, "every x is on screen or in history"); +} + +#[test] +fn an_update_shows_at_its_end_or_150_ms_after_it_began() { + let now = std::time::Instant::now(); + let mut t = Term::new(80, 24, 100); + t.feed(b"before\x1b[?2026hafter"); + let text = t.screen().contents(); + assert!(text.contains("before") && !text.contains("after"), "{text}"); + assert!(!t.release_stale(now)); + let since = t.held_since; + t.feed(b"\x1b[?2026hmore"); + assert_eq!(t.held_since, since, "a second begin does not re-arm it"); + assert!(t.release_stale(now + std::time::Duration::from_millis(151))); + t.parse(None); + assert_eq!(t.parser.sync_bytes_count(), 0, "vte never buffers an update itself"); + assert!(t.screen().contents().contains("aftermore")); + + let mut t = Term::new(80, 24, 100); + t.feed(b"\x1b[?2026hx\x1b[?2026l"); + assert!(t.screen().contents().contains('x'), "shown at its end"); + + let mut t = Term::new(80, 24, 100); + t.feed(b"\x1b[?1000h\x1b[?2026h"); + t.mouse_off(); + assert!(t.wants_mouse(), "nerd's bytes wait in the update, in their place"); + t.release_hold(); + t.parse(None); + assert!(!t.wants_mouse()); + + let mut t = Term::new(80, 24, 100); + t.feed(b"\x1b[?1000h\x1b[?2026hy"); + t.modes_off(); + assert!(!t.queued() && t.held.is_empty() && !t.wants_mouse()); + assert!(!t.screen().contents().contains('y')); +} + +#[test] +fn the_caps_that_fired_are_told_once() { + let mut t = Term::new(80, 24, 100); + for _ in 0..5 { + t.feed(&[&b"\x1b]0;"[..], &vec![b'x'; 5000], b"\x07x\x1b[500b"].concat()); + } + let hits = t.take_hits(); + assert_eq!(hits, HIT_OSC | HIT_REP); + assert_eq!(t.take_hits(), 0); + assert_eq!(describe(hits), "an OSC over 4096 characters, a repeat wider than the pane"); + for (name, raw) in corpus() { + let mut t = Term::new(120, 40, 100); + t.feed(&raw); + assert_eq!(t.take_hits(), 0, "{name}"); + } +} + +/// The slowest of three runs of one parse of `bytes` in a single slice, after `setup`, in ms. +fn slice_ms(cols: u16, rows: u16, setup: &[u8], bytes: &[u8]) -> f64 { + (0..3) + .map(|_| { + let mut t = Term::new(cols, rows, 1000); + t.feed(setup); + t.take(bytes); + let start = std::time::Instant::now(); + assert!(t.parse_in(None, bytes.len().max(1))); + start.elapsed().as_secs_f64() * 1e3 + }) + .fold(0.0, f64::max) +} + +fn cut(pattern: &[u8], n: usize) -> Vec { + pattern.iter().copied().cycle().take(n).collect() +} + +/// S6: `cargo test --release --bin nerd -- --ignored --nocapture measure_slices`. +#[test] +#[ignore] +fn measure_slices() { + let sizes = [(200u16, 50u16), (600, 170), (1000, 280)]; + for (cols, rows) in sizes { + let row: Vec = (8..=13).map(|p| format!("{}={:.2}", 1 << p, slice_ms(cols, rows, b"", &cut(b"\x1b#8", 1 << p)))).collect(); + println!("DECALN {cols}x{rows}: {}", row.join(" ")); + } + for (cols, rows) in sizes { + let rep = filter(&cut(b"a\x1b[65535b", SLICE), cols).0; + let cbt = cut(b"\x1b[65535Z", SLICE); + let plain = cut(b"plain text of a line, 0123456789\r\n", SLICE); + let osc = filter(&[&b"\x1b]0;"[..], &vec![b'x'; 1 << 20], b"\x07"].concat(), cols).0[..SLICE].to_vec(); + let marks = "\u{301}".repeat(SLICE / 2).into_bytes(); + let full = format!("a{}", "\u{301}".repeat(30)); + println!( + "SLICE={SLICE} {cols}x{rows}: rep={:.4} cbt={:.4} plain={:.4} osc={:.4} marks-on-full={:.4}", + slice_ms(cols, rows, b"", &rep), + slice_ms(cols, rows, format!("\x1b[3g\x1b[{cols}G").as_bytes(), &cbt), + slice_ms(cols, rows, b"", &plain), + slice_ms(cols, rows, b"", &osc), + slice_ms(cols, rows, full.as_bytes(), &marks), + ); + } + // The largest synchronized frame of the corpus, released at its end, parsed through `parse` in slices. + for (name, raw) in corpus() { + let (mut best, mut from) = ((0, 0), None); + for i in 0..raw.len().saturating_sub(7) { + match &raw[i..i + 8] { + b"\x1b[?2026h" if from.is_none() => from = Some(i), + b"\x1b[?2026l" => { + if let Some(f) = from.take() + && i + 8 - f > best.1 - best.0 + { + best = (f, i + 8); + } + } + _ => {} + } + } + if best.1 == 0 { + continue; + } + let frame = &raw[best.0..best.1]; + let mut t = Term::new(600, 170, 1000); + t.take(frame); + let start = std::time::Instant::now(); + t.parse(None); + println!("frame {name}: {} bytes parsed in {:.2} ms at 600x170", frame.len(), start.elapsed().as_secs_f64() * 1e3); + } + // Every cell of a default-Scrollback pane holding 30 marks, read as search reads it. + let mut t = Term::new(80, 24, 10_000); + t.feed(&b"\r\n".repeat(10_100)); + let mut proto = alacritty_terminal::term::cell::Cell { c: 'a', ..Default::default() }; + for _ in 0..30 { + proto.push_zerowidth('\u{301}'); + } + let g = t.term.grid_mut(); + for l in -(g.history_size() as i32)..g.screen_lines() as i32 { + for c in 0..g.columns() { + g[alacritty_terminal::index::Line(l)][Column(c)] = proto.clone(); + } + } + let start = std::time::Instant::now(); + let lines = t.all_lines(); + println!("all_lines 80x24 + {} history, every cell 30 marks: {:.1} ms", lines.len() - 24, start.elapsed().as_secs_f64() * 1e3); + // Plain throughput: 64 MiB of the corpus in 8 KiB chunks, today's path against the bounded one. + let all: Vec = corpus().into_iter().flat_map(|(_, raw)| raw).collect(); + let all: Vec = all.iter().copied().cycle().take(64 << 20).collect(); + for (label, mut t) in [("before", Term::unbounded(120, 40, 10_000)), ("after", Term::new(120, 40, 10_000))] { + let start = std::time::Instant::now(); + for c in all.chunks(8192) { + t.take(c); + while !t.parse(Some(std::time::Instant::now())) {} + } + println!("throughput {label}: {:.1} MB/s", all.len() as f64 / start.elapsed().as_secs_f64() / 1e6); + } + let mut t = Term::new(120, 40, 10_000); + let start = std::time::Instant::now(); + for c in all.chunks(8192) { + t.take(c); + t.parse(None); + } + println!("throughput after, no deadline: {:.1} MB/s", all.len() as f64 / start.elapsed().as_secs_f64() / 1e6); + let (mut f, mut out, mut marks, mut hits) = (Filter::default(), Vec::new(), Vec::new(), 0); + let start = std::time::Instant::now(); + for c in all.chunks(8192) { + out.clear(); + marks.clear(); + f.pass(c, 120, &mut hits, &mut out, &mut marks); + } + println!("filter alone: {:.1} MB/s", all.len() as f64 / start.elapsed().as_secs_f64() / 1e6); +}