返回 CodeWhale
modes.rs
根目录 / crates / tui / tests / support / qa_harness / modes.rs
1 //! Terminal-mode ledger built from the raw PTY output stream.
2 //!
3 //! The rendered frame cannot answer "did the TUI put the terminal back the way
4 //! it found it?" — alternate screen, bracketed paste, mouse capture, focus
5 //! reporting and the kitty keyboard protocol are all *modes*, and a mode that
6 //! was enabled and then disabled leaves the screen looking identical either
7 //! way. The only truthful evidence is the control stream itself.
8 //!
9 //! This ledger replays every `CSI ? <params> h|l` (DEC private mode set/reset)
10 //! in the transcript and records the **last** value seen for each mode number,
11 //! plus how many times the kitty keyboard stack was pushed (`CSI > <flags> u`)
12 //! and popped (`CSI < <n> u`).
13 //!
14 //! Deliberately vendor-agnostic: it never asserts on the order or grouping
15 //! crossterm happens to emit today, only on the final state of a mode number,
16 //! which is what an exiting terminal actually inherits.
17
18 use std::collections::BTreeMap;
19
20 /// DEC private mode numbers this suite reasons about by name.
21 pub mod mode {
22 /// Cursor visibility (DECTCEM).
23 pub const CURSOR_VISIBLE: u16 = 25;
24 /// X10-compatible mouse button tracking.
25 pub const MOUSE_BUTTON: u16 = 1000;
26 /// Button-event (drag) mouse tracking.
27 pub const MOUSE_DRAG: u16 = 1002;
28 /// Any-event mouse tracking.
29 pub const MOUSE_ANY: u16 = 1003;
30 /// Focus in / focus out reporting.
31 pub const FOCUS: u16 = 1004;
32 /// Alternate scroll: wheel events become arrow keys on the alt screen.
33 pub const ALTERNATE_SCROLL: u16 = 1007;
34 /// urxvt extended mouse coordinates.
35 pub const MOUSE_URXVT: u16 = 1015;
36 /// SGR extended mouse coordinates.
37 pub const MOUSE_SGR: u16 = 1006;
38 /// Alternate screen buffer with save/restore cursor.
39 pub const ALT_SCREEN: u16 = 1049;
40 /// Bracketed paste.
41 pub const BRACKETED_PASTE: u16 = 2004;
42 }
43
44 /// Every mode that must be off again once the process has exited, whatever
45 /// path it exited through. Cursor visibility is asserted separately because
46 /// its restored value is *on*, not off.
47 pub const MODES_THAT_MUST_NOT_LEAK: &[(u16, &str)] = &[
48 (mode::ALT_SCREEN, "alternate screen"),
49 (mode::BRACKETED_PASTE, "bracketed paste"),
50 (mode::FOCUS, "focus reporting"),
51 (mode::ALTERNATE_SCROLL, "alternate scroll"),
52 (mode::MOUSE_BUTTON, "mouse button tracking"),
53 (mode::MOUSE_DRAG, "mouse drag tracking"),
54 (mode::MOUSE_ANY, "mouse any-event tracking"),
55 (mode::MOUSE_SGR, "SGR mouse encoding"),
56 (mode::MOUSE_URXVT, "urxvt mouse encoding"),
57 ];
58
59 #[derive(Debug, Default, Clone)]
60 pub struct TerminalModeLedger {
61 final_state: BTreeMap<u16, bool>,
62 transitions: Vec<(u16, bool)>,
63 keyboard_pushes: usize,
64 keyboard_pops: usize,
65 }
66
67 impl TerminalModeLedger {
68 pub fn from_transcript(bytes: &[u8]) -> Self {
69 let mut ledger = Self::default();
70 let mut i = 0usize;
71 while i < bytes.len() {
72 if bytes[i] != 0x1b {
73 i += 1;
74 continue;
75 }
76 // Every sequence this ledger cares about is `ESC [ <intro> … <final>`.
77 let Some(&b'[') = bytes.get(i + 1) else {
78 i += 1;
79 continue;
80 };
81 let intro = match bytes.get(i + 2) {
82 Some(&b'?') => Intro::Private,
83 Some(&b'>') => Intro::KeyboardPush,
84 Some(&b'<') => Intro::KeyboardPop,
85 _ => {
86 i += 1;
87 continue;
88 }
89 };
90 let params_start = i + 3;
91 let mut cursor = params_start;
92 while matches!(bytes.get(cursor), Some(b) if b.is_ascii_digit() || *b == b';') {
93 cursor += 1;
94 }
95 let Some(&terminator) = bytes.get(cursor) else {
96 // Truncated tail: the child was killed mid-write. Stop rather
97 // than guessing at a sequence that was never completed.
98 break;
99 };
100 let params = &bytes[params_start..cursor];
101 match (intro, terminator) {
102 (Intro::Private, b'h') | (Intro::Private, b'l') => {
103 let enabled = terminator == b'h';
104 for part in params.split(|b| *b == b';') {
105 if let Some(number) = parse_u16(part) {
106 ledger.final_state.insert(number, enabled);
107 ledger.transitions.push((number, enabled));
108 }
109 }
110 }
111 (Intro::KeyboardPush, b'u') => ledger.keyboard_pushes += 1,
112 (Intro::KeyboardPop, b'u') => ledger.keyboard_pops += 1,
113 _ => {}
114 }
115 i = cursor + 1;
116 }
117 ledger
118 }
119
120 /// Final state of one DEC private mode, or `None` if the transcript never
121 /// mentioned it. `None` is not a failure — a terminal that never had a
122 /// mode enabled has nothing to restore.
123 pub fn state(&self, number: u16) -> Option<bool> {
124 self.final_state.get(&number).copied()
125 }
126
127 /// Whether the mode was ever enabled at any point in the transcript.
128 pub fn was_ever_enabled(&self, number: u16) -> bool {
129 self.transitions
130 .iter()
131 .any(|(mode, enabled)| *mode == number && *enabled)
132 }
133
134 pub fn keyboard_pushes(&self) -> usize {
135 self.keyboard_pushes
136 }
137
138 pub fn keyboard_pops(&self) -> usize {
139 self.keyboard_pops
140 }
141
142 /// Modes that were switched on and never switched back off. This is the
143 /// exact failure the `^[[>5u` shell-pollution reports (#1583) describe.
144 pub fn leaked_modes(&self) -> Vec<(u16, &'static str)> {
145 MODES_THAT_MUST_NOT_LEAK
146 .iter()
147 .filter(|(number, _)| self.state(*number) == Some(true))
148 .copied()
149 .collect()
150 }
151
152 /// Human-readable ledger for failure output. Printed next to the frame
153 /// dump so a timeout or a leak names the mode instead of the byte offset.
154 pub fn debug_dump(&self) -> String {
155 let mut out = String::from("== terminal modes ==\n");
156 for (number, enabled) in &self.final_state {
157 let name = MODES_THAT_MUST_NOT_LEAK
158 .iter()
159 .find(|(mode, _)| mode == number)
160 .map(|(_, name)| *name)
161 .unwrap_or(match *number {
162 mode::CURSOR_VISIBLE => "cursor visible",
163 _ => "unclassified",
164 });
165 out.push_str(&format!(
166 " ?{number:<5} {:<3} ({name})\n",
167 if *enabled { "on" } else { "off" }
168 ));
169 }
170 out.push_str(&format!(
171 " keyboard enhancement: {} push / {} pop\n",
172 self.keyboard_pushes, self.keyboard_pops
173 ));
174 out
175 }
176 }
177
178 #[derive(Clone, Copy, PartialEq, Eq)]
179 enum Intro {
180 Private,
181 KeyboardPush,
182 KeyboardPop,
183 }
184
185 fn parse_u16(bytes: &[u8]) -> Option<u16> {
186 if bytes.is_empty() {
187 return None;
188 }
189 std::str::from_utf8(bytes).ok()?.parse().ok()
190 }
191
192 #[cfg(test)]
193 mod tests {
194 use super::*;
195
196 #[test]
197 fn last_write_wins_per_mode_number() {
198 let ledger =
199 TerminalModeLedger::from_transcript(b"\x1b[?1049h\x1b[?2004h\x1b[?2004l\x1b[?1049l");
200
201 assert_eq!(ledger.state(mode::ALT_SCREEN), Some(false));
202 assert_eq!(ledger.state(mode::BRACKETED_PASTE), Some(false));
203 assert!(ledger.was_ever_enabled(mode::BRACKETED_PASTE));
204 assert!(ledger.leaked_modes().is_empty());
205 }
206
207 #[test]
208 fn semicolon_grouped_parameters_each_get_a_state() {
209 let ledger = TerminalModeLedger::from_transcript(b"\x1b[?1000;1002;1006h");
210
211 for number in [mode::MOUSE_BUTTON, mode::MOUSE_DRAG, mode::MOUSE_SGR] {
212 assert_eq!(ledger.state(number), Some(true));
213 }
214 assert_eq!(ledger.leaked_modes().len(), 3);
215 }
216
217 #[test]
218 fn keyboard_enhancement_push_and_pop_are_counted_separately() {
219 let ledger = TerminalModeLedger::from_transcript(b"\x1b[>1u\x1b[>1u\x1b[<1u");
220
221 assert_eq!(ledger.keyboard_pushes(), 2);
222 assert_eq!(ledger.keyboard_pops(), 1);
223 }
224
225 #[test]
226 fn unrelated_sgr_and_cursor_sequences_are_ignored() {
227 let ledger =
228 TerminalModeLedger::from_transcript(b"\x1b[38;2;10;20;30mhello\x1b[2J\x1b[?25l");
229
230 assert_eq!(ledger.state(mode::CURSOR_VISIBLE), Some(false));
231 assert_eq!(ledger.state(mode::ALT_SCREEN), None);
232 }
233
234 #[test]
235 fn a_transcript_truncated_mid_sequence_does_not_panic() {
236 let ledger = TerminalModeLedger::from_transcript(b"\x1b[?1049h\x1b[?200");
237
238 assert_eq!(ledger.state(mode::ALT_SCREEN), Some(true));
239 assert_eq!(
240 ledger.leaked_modes(),
241 vec![(mode::ALT_SCREEN, "alternate screen")]
242 );
243 }
244 }
245
245 lines RUST