mira/term.rs
1//! A terminal, hand-rolled on `libc`.
2//!
3//! ratatui is the obvious answer, and adding it to this workspace resolves 35
4//! crates that are not already here — a 30% increase on a tree whose size (117
5//! crates, 5.63 MiB) is a stated property of the product.
6//! What it buys over this file is a constraint-solving layout engine and a
7//! damage-tracked cell buffer; the TUI here has fixed panes and redraws one
8//! screenful per keystroke. So: `termios` for raw mode, `TIOCGWINSZ` for the
9//! size, `poll(2)` for input, three `sigaction`s to survive a resize and a
10//! `kill`, ANSI for the rest. `libc` is already in the tree for `statfs`.
11//!
12//! Unix only, which is the same bet `mmap`, `SIGTERM` and the filesystem guard
13//! already make.
14
15use std::io::{self, IsTerminal, Read, Write};
16use std::sync::OnceLock;
17
18/// The terminal settings as they were before [`Term::enter`].
19///
20/// A static rather than a field because the panic hook has to reach them, and
21/// the hook outlives any borrow we could hand it. `panic = "abort"` in the
22/// release profile does not skip hooks — it skips *unwinding* — so this is
23/// still the last thing that runs before a crash, and without it a panic leaves
24/// the shell in raw mode with no echo and no cursor.
25static ORIG: OnceLock<libc::termios> = OnceLock::new();
26
27pub struct Term {
28 /// Bytes read from stdin but not yet consumed as a key.
29 ///
30 /// One `read` can return a whole escape sequence, several keystrokes from a
31 /// fast typist, or half of either. Without this, the tail of a burst is
32 /// silently dropped.
33 pending: Vec<u8>,
34 out: io::BufWriter<io::Stdout>,
35}
36
37impl Term {
38 pub fn enter() -> io::Result<Term> {
39 // Checked before `tcgetattr`, which would otherwise report a redirected
40 // stdin as `ENOTTY` — "Inappropriate ioctl for device" — and says nothing
41 // at all about a redirected stdout, which instead fills the file with
42 // escape sequences.
43 if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
44 return Err(io::Error::new(
45 io::ErrorKind::Unsupported,
46 "not a terminal; the TUI needs stdin and stdout on a tty",
47 ));
48 }
49 // SAFETY: `termios` is integers and the `c_cc` byte array — no pointer,
50 // no niche, no field for which zero is not a value — so all-zero is a
51 // valid `termios` to hold until `tcgetattr` overwrites it.
52 let mut t: libc::termios = unsafe { std::mem::zeroed() };
53 // SAFETY: `&mut t` is a live, correctly typed, initialised `termios`, and
54 // `tcgetattr` writes nothing past its end.
55 ok_or_errno(unsafe { libc::tcgetattr(0, &mut t) })?;
56 let _ = ORIG.set(t);
57
58 let mut raw = t;
59 // SAFETY: `raw` is a copy of the struct `tcgetattr` just filled, so
60 // `cfmakeraw` reads and writes only initialised fields of a live struct.
61 unsafe { libc::cfmakeraw(&mut raw) };
62 // VMIN 0 / VTIME 0: `read` returns immediately with whatever is there.
63 // Blocking is `poll`'s job, and doing it in both places is how a
64 // keystroke ends up waiting for the next one.
65 raw.c_cc[libc::VMIN] = 0;
66 raw.c_cc[libc::VTIME] = 0;
67 // SAFETY: `raw` is fully initialised — copied out of `tcgetattr`, edited
68 // field by field — and `tcsetattr` only reads it, for the duration of
69 // the call.
70 ok_or_errno(unsafe { libc::tcsetattr(0, libc::TCSANOW, &raw) })?;
71
72 let prev = std::panic::take_hook();
73 std::panic::set_hook(Box::new(move |info| {
74 restore();
75 prev(info);
76 }));
77
78 // SIGWINCH so a resize breaks the `poll` in `key` and the loop repaints
79 // at the new size; SIGTERM and SIGHUP so a `kill` or a closed ssh
80 // session gives the terminal back the way the panic hook does. Their
81 // default dispositions — discard, and die on the spot — are both wrong
82 // for a process that owns the screen.
83 on_signal(libc::SIGWINCH, winch);
84 on_signal(libc::SIGTERM, bail);
85 on_signal(libc::SIGHUP, bail);
86
87 let mut out = io::BufWriter::new(io::stdout());
88 // Alternate screen, then hide the cursor. Leaving on the alternate
89 // screen is what puts the user's scrollback back the way they left it.
90 out.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J")?;
91 out.flush()?;
92 Ok(Term {
93 pending: Vec::with_capacity(64),
94 out,
95 })
96 }
97
98 /// Visible size, re-read every frame.
99 ///
100 /// The `SIGWINCH` handler [`enter`](Term::enter) installs records nothing;
101 /// it exists only to interrupt the `poll` the draw loop is parked in. This
102 /// call is what learns the new size, on the frame that interruption paints.
103 pub fn size(&self) -> (usize, usize) {
104 // SAFETY: `winsize` is four `u16`s, so zero is a valid value — and it
105 // has to be, because a failing `ioctl` leaves the struct untouched and
106 // the `ws_col == 0` arm below is what reads it back.
107 let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
108 // SAFETY: TIOCGWINSZ is the request whose argument is `*mut winsize`,
109 // which is exactly what `&mut ws` is. `ioctl` is variadic, so nothing
110 // checks that pairing but this comment — a different request constant
111 // with this argument is the way the call goes wrong.
112 if unsafe { libc::ioctl(1, libc::TIOCGWINSZ as _, &mut ws) } != 0 || ws.ws_col == 0 {
113 return (80, 24);
114 }
115 (ws.ws_col as usize, ws.ws_row as usize)
116 }
117
118 /// Paint one frame: home the cursor, write every row, erase whatever the
119 /// last frame left below.
120 ///
121 /// A full repaint rather than a diff. At 200×60 that is 12 000 cells, and
122 /// one `write` of 30 KB costs less than tracking which of them changed.
123 pub fn draw(&mut self, rows: &[String]) -> io::Result<()> {
124 self.out.write_all(b"\x1b[H")?;
125 for row in rows {
126 self.out.write_all(row.as_bytes())?;
127 self.out.write_all(b"\x1b[K\r\n")?;
128 }
129 self.out.write_all(b"\x1b[J")?;
130 self.out.flush()
131 }
132
133 /// Next key, or `None` if `timeout_ms` passed with nothing to read.
134 pub fn key(&mut self, timeout_ms: i32) -> io::Result<Option<Key>> {
135 loop {
136 match parse(&mut self.pending) {
137 Parsed::Key(k) => return Ok(Some(k)),
138 Parsed::Need => {}
139 }
140 // A lone ESC and the start of an arrow key are the same first byte.
141 // The only way to tell them apart is that the rest of the sequence
142 // is already in flight, so wait a beat before calling it ESC.
143 let wait = if self.pending.is_empty() {
144 timeout_ms
145 } else {
146 20
147 };
148 if !poll_in(wait)? {
149 return Ok(match self.pending.is_empty() {
150 true => None,
151 // Incomplete after the grace period: emit the first byte for
152 // what it is and resynchronise rather than wedging.
153 false => Some(take_one(&mut self.pending)),
154 });
155 }
156 let mut buf = [0u8; 256];
157 let n = io::stdin().read(&mut buf)?;
158 if n == 0 {
159 return Ok(Some(Key::Ctrl('c')));
160 }
161 self.pending.extend_from_slice(&buf[..n]);
162 }
163 }
164}
165
166enum Parsed {
167 Key(Key),
168 Need,
169}
170
171/// One key off the front of `b`, or [`Parsed::Need`] if what is there could
172/// still grow into a longer sequence.
173///
174/// Free rather than a method so the tests can drive it with a plain `Vec`: a
175/// `Term` restores the terminal when it drops, and constructing one in a test
176/// sprays escape codes across the test runner's output.
177fn parse(b: &mut Vec<u8>) -> Parsed {
178 let Some(&first) = b.first() else {
179 return Parsed::Need;
180 };
181 let one = |b: &mut Vec<u8>, k| {
182 b.remove(0);
183 Parsed::Key(k)
184 };
185 match first {
186 0x1b => {
187 match b.get(1) {
188 None => Parsed::Need,
189 // CSI: parameters, then one byte in 0x40..=0x7e ends it.
190 Some(b'[') => {
191 let Some(end) = b[2..].iter().position(|c| (0x40..=0x7e).contains(c)) else {
192 return Parsed::Need;
193 };
194 let seq: Vec<u8> = b[2..2 + end + 1].to_vec();
195 b.drain(..3 + end);
196 Parsed::Key(csi(&seq))
197 }
198 // SS3, which is what some terminals send for the arrows in
199 // application cursor mode.
200 Some(b'O') => match b.get(2) {
201 None => Parsed::Need,
202 Some(&c) => {
203 b.drain(..3);
204 Parsed::Key(csi(&[c]))
205 }
206 },
207 // Alt+key. Nothing here binds one, so drop the modifier and keep
208 // the key rather than swallowing both.
209 Some(_) => {
210 b.remove(0);
211 Parsed::Need
212 }
213 }
214 }
215 b'\r' | b'\n' => one(b, Key::Enter),
216 b'\t' => one(b, Key::Tab),
217 0x7f | 0x08 => one(b, Key::Backspace),
218 c if c < 0x20 => one(b, Key::Ctrl((c + b'a' - 1) as char)),
219 c if c < 0x80 => one(b, Key::Char(c as char)),
220 c => {
221 // UTF-8 continuation bytes may not have arrived yet.
222 let len = match c {
223 0xc0..=0xdf => 2,
224 0xe0..=0xef => 3,
225 _ => 4,
226 };
227 if b.len() < len {
228 return Parsed::Need;
229 }
230 let s = String::from_utf8_lossy(&b[..len]).into_owned();
231 b.drain(..len);
232 Parsed::Key(s.chars().next().map_or(Key::Esc, Key::Char))
233 }
234 }
235}
236
237fn take_one(b: &mut Vec<u8>) -> Key {
238 match b.remove(0) {
239 0x1b => Key::Esc,
240 c if c < 0x20 => Key::Ctrl((c + b'a' - 1) as char),
241 c => Key::Char(c as char),
242 }
243}
244
245impl Drop for Term {
246 fn drop(&mut self) {
247 restore();
248 }
249}
250
251fn csi(seq: &[u8]) -> Key {
252 match seq {
253 b"A" => Key::Up,
254 b"B" => Key::Down,
255 b"C" => Key::Right,
256 b"D" => Key::Left,
257 b"H" | b"1~" | b"7~" => Key::Home,
258 b"F" | b"4~" | b"8~" => Key::End,
259 b"5~" => Key::PageUp,
260 b"6~" => Key::PageDown,
261 b"Z" => Key::BackTab,
262 _ => Key::Esc,
263 }
264}
265
266/// Put the terminal back. Idempotent, because `Drop`, the panic hook and a
267/// fatal signal can all reach it and a double panic would otherwise run it
268/// twice.
269///
270/// Every call in here is async-signal-safe — `tcsetattr` and `write` are on
271/// POSIX's list, and `OnceLock::get` is one atomic load — because [`bail`] runs
272/// it from a signal handler. `io::stdout()` is the thing that would not do:
273/// taking its lock in a handler that interrupted the thread already holding it
274/// is a deadlock at exactly the moment the user wants their terminal back.
275fn restore() {
276 if let Some(t) = ORIG.get() {
277 // SAFETY: `t` borrows the `termios` `enter` filled with `tcgetattr` and
278 // nothing has written it since — `OnceLock` hands out no `&mut` after
279 // `set`. Reached from the `bail` handler, and `tcsetattr` is on POSIX's
280 // async-signal-safe list, so interrupting a `tcsetattr` with this one is
281 // defined.
282 unsafe { libc::tcsetattr(0, libc::TCSANOW, t) };
283 }
284 const OFF: &[u8] = b"\x1b[?25h\x1b[?1049l";
285 // SAFETY: `OFF` is a `'static` slice, so its pointer is valid for the
286 // `OFF.len()` bytes claimed for as long as the program runs, and `write`
287 // only reads them. Async-signal-safe, which is why this is not `stdout`.
288 unsafe { libc::write(1, OFF.as_ptr().cast(), OFF.len()) };
289}
290
291/// Install `h` for `sig`, without `SA_RESTART`.
292///
293/// The flag is omitted because it buys nothing here, not because [`poll_in`]
294/// needs it: `poll(2)` is on signal(7)'s list of calls the kernel never
295/// restarts whatever the flag says, and the `read(2)` in the same loop runs
296/// under VMIN 0 / VTIME 0, so it returns immediately and is never sitting in a
297/// restartable wait either. What makes a resize wake `poll_in` is installing a
298/// handler at all — SIGWINCH's default disposition is to discard the signal,
299/// and a discarded signal interrupts nothing.
300fn on_signal(sig: libc::c_int, h: unsafe extern "C" fn(libc::c_int)) {
301 // SAFETY: `sigaction` is POD, and all-zero is its "no flags, SIG_DFL"
302 // value — the one the two writes below then replace.
303 let mut sa: libc::sigaction = unsafe { std::mem::zeroed() };
304 sa.sa_sigaction = h as usize;
305 // SAFETY: `sa` is initialised and outlives the call, which copies it;
306 // `sigemptyset` writes only `sa_mask`. The load-bearing part is `sa_flags`,
307 // left at zero by the `zeroed` above: with SA_SIGINFO clear the kernel calls
308 // `sa_sigaction` with the single `c_int` that `h`'s type declares, so
309 // setting that flag without widening `h`'s signature is what would break
310 // this. A null `oldact` means "do not report the previous disposition",
311 // which `sigaction(2)` permits. Both handlers this is ever called with —
312 // [`winch`] and [`bail`] — are async-signal-safe.
313 unsafe {
314 libc::sigemptyset(&mut sa.sa_mask);
315 libc::sigaction(sig, &sa, std::ptr::null_mut());
316 }
317}
318
319/// A resize. Nothing to do in the handler — the delivery itself is the message,
320/// and it arrives as an `EINTR` in [`poll_in`].
321unsafe extern "C" fn winch(_: libc::c_int) {}
322
323/// Give the terminal back, then die.
324///
325/// Installing a handler removed the default disposition, so returning would
326/// resume a process the user asked to end; `_exit` rather than `exit` because
327/// the latter runs atexit handlers that are not async-signal-safe, and 128+n is
328/// the status a shell reports for a signal death.
329unsafe extern "C" fn bail(sig: libc::c_int) {
330 restore();
331 // SAFETY: `_exit` dereferences nothing and never returns. It is
332 // async-signal-safe, which is the entire reason it is here rather than
333 // `exit` — see the doc above.
334 unsafe { libc::_exit(128 + sig) };
335}
336
337/// The `termios` calls' contract: `0`, or `-1` and the reason is in `errno`.
338///
339/// Read here rather than at the call site because nothing runs in between —
340/// `rc` is already a value by the time this is entered, and Rust code cannot
341/// touch `errno` — so the read is as prompt as an inline one, and the two call
342/// sites in [`enter`](Term::enter) stop being four lines each.
343fn ok_or_errno(rc: libc::c_int) -> io::Result<()> {
344 match rc {
345 0 => Ok(()),
346 _ => Err(io::Error::last_os_error()),
347 }
348}
349
350fn poll_in(timeout_ms: i32) -> io::Result<bool> {
351 let mut p = libc::pollfd {
352 fd: 0,
353 events: libc::POLLIN,
354 revents: 0,
355 };
356 // SAFETY: `&mut p` points at exactly the one initialised `pollfd` the count
357 // of `1` claims, it lives across the call, and `poll` writes only `revents`.
358 let n = unsafe { libc::poll(&mut p, 1, timeout_ms) };
359 if n >= 0 {
360 return Ok(n > 0);
361 }
362 let e = io::Error::last_os_error();
363 // A resize interrupts the poll, which is the only reason `enter` installs a
364 // SIGWINCH handler at all — the default disposition discards it and this
365 // arm would be unreachable. Reporting "nothing to read" sends the caller
366 // back around the draw loop, which re-reads the size — which is exactly the
367 // handling a resize wants, so it is not retried here.
368 match e.kind() {
369 io::ErrorKind::Interrupted => Ok(false),
370 _ => Err(e),
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum Key {
376 Char(char),
377 Ctrl(char),
378 Up,
379 Down,
380 Left,
381 Right,
382 Enter,
383 Esc,
384 Tab,
385 BackTab,
386 Backspace,
387 Home,
388 End,
389 PageUp,
390 PageDown,
391}
392
393pub const RESET: &str = "\x1b[0m";
394pub const BOLD: &str = "\x1b[1m";
395pub const DIM: &str = "\x1b[2m";
396pub const REV: &str = "\x1b[7m";
397pub const RED: &str = "\x1b[31m";
398pub const GREEN: &str = "\x1b[32m";
399pub const YELLOW: &str = "\x1b[33m";
400pub const BLUE: &str = "\x1b[34m";
401pub const MAGENTA: &str = "\x1b[35m";
402pub const CYAN: &str = "\x1b[36m";
403
404/// One line of the frame, built left to right with its visible width tracked
405/// separately from its bytes.
406///
407/// Styling is inline ANSI, so `buf.len()` says nothing about how wide the line
408/// renders; every truncation and every pad has to go through `width`. That is
409/// the entire reason this type exists rather than a `String`.
410pub struct Row {
411 buf: String,
412 width: usize,
413 max: usize,
414}
415
416impl Row {
417 pub fn new(max: usize) -> Row {
418 Row {
419 buf: String::with_capacity(max + 32),
420 width: 0,
421 max,
422 }
423 }
424
425 pub fn left(&self) -> usize {
426 self.max.saturating_sub(self.width)
427 }
428
429 /// Move the right edge, so a right-aligned field can reserve its space
430 /// before the left-hand side is written and be clipped away by it.
431 pub fn cap(&mut self, max: usize) -> &mut Row {
432 self.max = max;
433 self
434 }
435
436 /// Append text, truncated to what is left of the line.
437 ///
438 /// ponytail: one column per `char`. A CJK log body renders one cell wide per
439 /// character here and will run past the right edge; the fix is a
440 /// `unicode-width` table, which is a crate, and the payload this reads is
441 /// attribute keys and metric names. Revisit if a user shows up with a
442 /// wide-character body.
443 pub fn put(&mut self, style: &str, s: &str) -> &mut Row {
444 let left = self.left();
445 if left == 0 {
446 return self;
447 }
448 if !style.is_empty() {
449 self.buf.push_str(style);
450 }
451 let mut n = 0;
452 for c in s.chars() {
453 if n == left {
454 break;
455 }
456 // A raw control byte in a log body would move the cursor.
457 self.buf.push(if (c as u32) < 0x20 { '·' } else { c });
458 n += 1;
459 }
460 if !style.is_empty() {
461 self.buf.push_str(RESET);
462 }
463 self.width += n;
464 self
465 }
466
467 pub fn plain(&mut self, s: &str) -> &mut Row {
468 self.put("", s)
469 }
470
471 /// Append an already-rendered line of known visible `width`, byte for byte.
472 ///
473 /// [`put`](Row::put) would rewrite its ESC bytes to `·` and count each one
474 /// as a column, which is right for a log body and wrong for a line that came
475 /// out of another `Row`. Nothing here can recover the width from the bytes,
476 /// so the caller states it — pass a line finished with [`done`](Row::done),
477 /// which pads to exactly the width it was built for.
478 pub fn raw(&mut self, s: &str, width: usize) -> &mut Row {
479 self.buf.push_str(s);
480 self.width += width;
481 self
482 }
483
484 /// Pad with spaces until the cursor sits at `col`. Never truncates: a
485 /// column that overflowed its slot pushes the next one right rather than
486 /// losing it.
487 pub fn pad_to(&mut self, col: usize) -> &mut Row {
488 while self.width < col.min(self.max) {
489 self.buf.push(' ');
490 self.width += 1;
491 }
492 self
493 }
494
495 /// Repeat `c` `n` times, clipped to the line.
496 pub fn repeat(&mut self, style: &str, c: char, n: usize) -> &mut Row {
497 let n = n.min(self.left());
498 if n == 0 {
499 return self;
500 }
501 if !style.is_empty() {
502 self.buf.push_str(style);
503 }
504 for _ in 0..n {
505 self.buf.push(c);
506 }
507 if !style.is_empty() {
508 self.buf.push_str(RESET);
509 }
510 self.width += n;
511 self
512 }
513
514 /// Finish the line, padded to full width so a selected row's reverse-video
515 /// background reaches the right edge.
516 pub fn fill(mut self, style: &str) -> String {
517 if !style.is_empty() {
518 // Re-open the style over the padding only; the text has already
519 // closed its own.
520 self.buf.push_str(style);
521 }
522 while self.width < self.max {
523 self.buf.push(' ');
524 self.width += 1;
525 }
526 self.buf.push_str(RESET);
527 self.buf
528 }
529
530 pub fn done(self) -> String {
531 self.fill("")
532 }
533}
534
535#[cfg(test)]
536pub(crate) mod tests {
537 use super::*;
538
539 /// Width accounting has to ignore the escape sequences, or every styled
540 /// line silently truncates early.
541 #[test]
542 fn styling_does_not_count_against_the_width() {
543 let mut r = Row::new(10);
544 r.put(RED, "abc").plain("de");
545 assert_eq!(r.left(), 5);
546 let s = r.done();
547 assert!(s.contains(RED));
548 // 10 visible columns, whatever the byte length.
549 let visible: String = strip(&s);
550 assert_eq!(visible, "abcde ");
551 }
552
553 #[test]
554 fn text_is_clipped_at_the_edge_and_control_bytes_are_defanged() {
555 let mut r = Row::new(6);
556 r.plain("a\nb").plain("xxxxxxxx");
557 assert_eq!(strip(&r.done()), "a·bxxx");
558 }
559
560 #[test]
561 fn pad_to_never_moves_backwards() {
562 let mut r = Row::new(12);
563 r.plain("overlong").pad_to(4).plain("|");
564 assert_eq!(strip(&r.done()), "overlong| ");
565 }
566
567 /// The sequences the app actually binds, including the ones that arrive
568 /// split across two reads.
569 #[test]
570 fn escape_sequences_decode_to_keys() {
571 let mut pending: Vec<u8> = Vec::new();
572 let mut keys = |bytes: &[u8]| {
573 pending.extend_from_slice(bytes);
574 let mut out = Vec::new();
575 while let Parsed::Key(k) = parse(&mut pending) {
576 out.push(k);
577 }
578 out
579 };
580 assert_eq!(keys(b"\x1b[A\x1b[B"), vec![Key::Up, Key::Down]);
581 assert_eq!(keys(b"\x1b[5~\x1b[6~"), vec![Key::PageUp, Key::PageDown]);
582 assert_eq!(keys(b"\x1bOD"), vec![Key::Left]);
583 assert_eq!(
584 keys(b"jk\r\x7f"),
585 vec![Key::Char('j'), Key::Char('k'), Key::Enter, Key::Backspace]
586 );
587 assert_eq!(keys(b"\x03"), vec![Key::Ctrl('c')]);
588 // Split mid-sequence: nothing until the rest lands. Both prefixes, since
589 // CSI and SS3 have separate "could still grow" arms.
590 assert_eq!(keys(b"\x1b["), vec![]);
591 assert_eq!(keys(b"C"), vec![Key::Right]);
592 assert_eq!(keys(b"\x1bO"), vec![]);
593 assert_eq!(keys(b"A"), vec![Key::Up]);
594 // Multi-byte UTF-8, likewise, at every lead-byte width — the width is
595 // read off the lead byte, so a wrong row in that table eats the next key.
596 assert_eq!(keys(&[0xc3]), vec![]);
597 assert_eq!(keys(&[0xa9]), vec![Key::Char('é')]);
598 assert_eq!(keys(&[0xe2, 0x82]), vec![]);
599 assert_eq!(keys(&[0xac]), vec![Key::Char('€')]);
600 assert_eq!(keys(&[0xf0, 0x9f, 0x98]), vec![]);
601 assert_eq!(keys(&[0x80]), vec![Key::Char('😀')]);
602 }
603
604 /// Every sequence in the `csi` table, because terminals disagree about which
605 /// one they send for the same physical key and the table exists to absorb
606 /// that. An unrecognised one is `Esc`, not a dropped byte: the app treats
607 /// `Esc` as "cancel", which is the safe reading of a key we do not know.
608 #[test]
609 fn one_key_arrives_in_as_many_spellings_as_there_are_terminals() {
610 // `None` is [`Parsed::Need`]: what is there could still grow into a
611 // longer sequence, so no key comes out of it yet.
612 fn key(bytes: &[u8]) -> Option<Key> {
613 match parse(&mut bytes.to_vec()) {
614 Parsed::Key(k) => Some(k),
615 Parsed::Need => None,
616 }
617 }
618 // A CSI with no final byte yet is the "could still grow" answer, not a
619 // key: the table below would otherwise be reading half a sequence.
620 assert_eq!(key(b"\x1b[1"), None);
621 for (bytes, want) in [
622 (&b"\x1b[H"[..], Some(Key::Home)),
623 (b"\x1b[1~", Some(Key::Home)),
624 (b"\x1b[7~", Some(Key::Home)),
625 (b"\x1b[F", Some(Key::End)),
626 (b"\x1b[4~", Some(Key::End)),
627 (b"\x1b[8~", Some(Key::End)),
628 (b"\x1b[Z", Some(Key::BackTab)),
629 (b"\x1b[C", Some(Key::Right)),
630 (b"\x1bOA", Some(Key::Up)),
631 // Not in the table, and not worth guessing at.
632 (b"\x1b[200~", Some(Key::Esc)),
633 (b"\t", Some(Key::Tab)),
634 (b"\x08", Some(Key::Backspace)),
635 (b"\n", Some(Key::Enter)),
636 // Nothing at all is not a key either: `key` is called in a loop that
637 // polls again, and a `Parsed::Key` here would be one nobody pressed.
638 (b"", None),
639 // Half of a CSI and half of an SS3, which is how they arrive when
640 // the read lands mid-sequence.
641 (b"\x1b[", None),
642 (b"\x1bO", None),
643 ] {
644 assert_eq!(key(bytes), want, "{bytes:?}");
645 }
646
647 // Alt+x: the modifier is dropped and the key kept, so the next call sees
648 // a bare `x` rather than both bytes being swallowed.
649 let mut b = b"\x1bx".to_vec();
650 assert!(matches!(parse(&mut b), Parsed::Need));
651 assert_eq!(key(&b), Some(Key::Char('x')));
652
653 // What `key` falls back to when a sequence never completes: the first
654 // byte for what it is, then resynchronise. A lone ESC is the case that
655 // matters — it is how the filter box gets cancelled.
656 for (byte, want) in [
657 (0x1b, Key::Esc),
658 (0x03, Key::Ctrl('c')),
659 (b'q', Key::Char('q')),
660 ] {
661 let mut b = vec![byte, b'!'];
662 assert_eq!(take_one(&mut b), want);
663 assert_eq!(b, b"!");
664 }
665 }
666
667 /// The three things `Row` does that `put` does not: reserve space from the
668 /// right, re-emit an already-rendered line without re-counting its escapes,
669 /// and pad under a style so a selected row's background reaches the edge.
670 #[test]
671 fn a_row_composes_out_of_other_rows_without_recounting_their_escapes() {
672 // A right-aligned field reserves its space by moving the right edge in,
673 // writing the left side, then letting it back out.
674 let mut r = Row::new(20);
675 r.plain("left").cap(20).pad_to(12).plain("right");
676 assert_eq!(strip(&r.done()), "left right ");
677
678 // `raw` takes the caller's word for the width. `put` would have counted
679 // the 4 escape bytes of RED as 4 columns and rewritten them to `·`.
680 let inner = {
681 let mut i = Row::new(5);
682 i.put(RED, "ab");
683 i.done()
684 };
685 let mut outer = Row::new(10);
686 outer.raw(&inner, 5).plain("xy");
687 let s = outer.done();
688 assert!(s.contains(RED), "the inner styling survived byte for byte");
689 assert_eq!(strip(&s), "ab xy ");
690
691 // `repeat` clips like `put` does, and a zero-width repeat writes nothing
692 // at all — not an empty style pair, which would still be bytes.
693 let mut r = Row::new(4);
694 r.repeat(DIM, '-', 99);
695 assert_eq!(strip(&r.done()), "----");
696 let mut r = Row::new(0);
697 r.repeat(DIM, '-', 3).put(RED, "x");
698 assert_eq!(r.done(), RESET, "nothing fits, so nothing is written");
699
700 // `fill` re-opens the style over the padding: the highlight on a selected
701 // row has to reach the right edge, not stop where the text does.
702 let mut r = Row::new(6);
703 r.plain("ab");
704 let s = r.fill(REV);
705 assert!(s.ends_with(&format!("{REV} {RESET}")), "{s:?}");
706 }
707
708 /// The branch that fires in anger: `mira mira` in a pipeline, in CI, or
709 /// under a process supervisor. The rest of the syscall half runs on a real
710 /// pty in [`the_terminal_half_runs_against_a_real_pty`].
711 #[test]
712 fn the_tui_refuses_a_stdin_that_is_not_a_terminal() {
713 let e = Term::enter()
714 .err()
715 .expect("cargo test does not run on a tty");
716 assert_eq!(e.kind(), io::ErrorKind::Unsupported);
717 assert!(e.to_string().contains("needs stdin and stdout on a tty"));
718 }
719
720 /// The other half of the same refusal: if a `termios` call fails anyway,
721 /// `enter` reports why instead of pressing on with a terminal it has half
722 /// configured. Provoked on a pipe, since `enter` turns away everything that
723 /// is not a tty before it reaches either call.
724 #[test]
725 fn a_failed_termios_call_carries_the_os_error() {
726 let mut fds = [-1; 2];
727 // SAFETY: `pipe` writes exactly the two `c_int`s this array holds.
728 assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
729 // SAFETY: all-zero is a valid `termios`, as in `enter` — and here both
730 // calls fail without writing it at all.
731 let mut t: libc::termios = unsafe { std::mem::zeroed() };
732 // SAFETY: `fds[0]` is open until the `close` below, and both calls get
733 // a live, correctly typed `termios`.
734 let get = ok_or_errno(unsafe { libc::tcgetattr(fds[0], &mut t) });
735 // SAFETY: as above; `tcsetattr` only reads it.
736 let set = ok_or_errno(unsafe { libc::tcsetattr(fds[0], libc::TCSANOW, &t) });
737 // SAFETY: both fds were opened by the `pipe` above and are owned by
738 // nothing else, so this is the only close of either.
739 unsafe {
740 libc::close(fds[0]);
741 libc::close(fds[1]);
742 }
743 for r in [get, set] {
744 // ENOTTY, not a generic "it did not work": the message the user
745 // sees is the difference between a redirect and a broken terminal.
746 assert_eq!(r.unwrap_err().raw_os_error(), Some(libc::ENOTTY));
747 }
748 }
749
750 /// The name of the test below, as `--exact` wants it.
751 const ON_POLL: &str = "term::tests::a_poll_that_fails_for_anything_but_a_signal_is_an_error";
752
753 /// EINTR is a resize and means "nothing to read"; every other `poll` error
754 /// has to come back as one, or the draw loop spins on a syscall that is
755 /// failing every time and never says so.
756 ///
757 /// `poll_in` polls fd 0 with nfds 1, which nothing outside the process can
758 /// make fail — except that both Linux and macOS reject an nfds above
759 /// RLIMIT_NOFILE with EINVAL, and the soft limit can be dropped to zero.
760 /// That is process-wide and a process that can open nothing is no use to
761 /// the rest of the suite, so it happens in a re-exec of this one test.
762 #[test]
763 fn a_poll_that_fails_for_anything_but_a_signal_is_an_error() {
764 if std::env::var_os("MIRA_POLL_CHILD").is_none() {
765 let out = std::process::Command::new(std::env::current_exe().unwrap())
766 .args(["--exact", ON_POLL, "--nocapture"])
767 .env("MIRA_POLL_CHILD", "1")
768 .output()
769 .unwrap();
770 let err = String::from_utf8_lossy(&out.stderr);
771 assert!(out.status.success(), "{err}");
772 return;
773 }
774 // SAFETY: `rlimit` is two integers, and `getrlimit` writes exactly the
775 // one live struct it is handed.
776 let mut orig: libc::rlimit = unsafe { std::mem::zeroed() };
777 // SAFETY: as above.
778 let read = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut orig) };
779 assert_eq!(read, 0, "getrlimit: {}", io::Error::last_os_error());
780 let none = libc::rlimit {
781 rlim_cur: 0,
782 rlim_max: orig.rlim_max,
783 };
784 // SAFETY: `setrlimit` only reads the struct, for the call's duration.
785 assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &none) }, 0);
786 let got = poll_in(0);
787 // Put it back before anything — the assertion's own panic machinery
788 // included — needs a descriptor. Lowering the soft limit is reversible
789 // precisely because the hard limit above was left alone.
790 // SAFETY: as above.
791 assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &orig) }, 0);
792 assert_eq!(got.unwrap_err().raw_os_error(), Some(libc::EINVAL));
793 }
794
795 fn strip(s: &str) -> String {
796 let mut out = String::new();
797 let mut it = s.chars();
798 while let Some(c) = it.next() {
799 if c == '\x1b' {
800 for c in it.by_ref() {
801 if c.is_ascii_alphabetic() {
802 break;
803 }
804 }
805 } else {
806 out.push(c);
807 }
808 }
809 out
810 }
811
812 /// The name of the test below, as `--exact` wants it.
813 const SELF: &str = "term::tests::the_terminal_half_runs_against_a_real_pty";
814
815 /// Everything in this file that talks to a terminal, against a terminal.
816 ///
817 /// `enter`, `size`, `draw`, `key`, `poll_in` and `restore` all address fd 0
818 /// and fd 1 directly, so the only honest way to run them is on a process
819 /// whose fd 0 and fd 1 are a tty — and it must not be *this* process, whose
820 /// stdin belongs to the test runner and whose termios the runner needs back.
821 #[test]
822 fn the_terminal_half_runs_against_a_real_pty() {
823 if std::env::var_os("MIRA_PTY_CHILD").is_some() {
824 return on_the_pty();
825 }
826 // Destructured rather than held as a `Pty`: the assertions below want
827 // the names in their format strings, and a format argument on its own
828 // line is a region that only a failing run ever executes.
829 let Pty {
830 screen,
831 err,
832 stalled,
833 trailing,
834 } = drive(
835 SELF,
836 &[],
837 &[
838 ("mira-pty-ready", b"\x1b[B"),
839 ("mira-pty-down", b"x"),
840 ("mira-pty-x", b"\x1b"),
841 ],
842 // The restore sequence is waited for, not snapshotted. The child's
843 // last write and the child's exit are not ordered with each other
844 // from over here: the wait returns on the exit, and the drain thread
845 // can still be one `read` behind on the master. Reading the buffer
846 // right then is a race that a Linux runner loses often enough to be
847 // seen.
848 "\x1b[?25h\x1b[?1049l",
849 );
850 // The child signs off by panicking on purpose, so any *other* panic is a
851 // failed assertion and this is where it gets read out.
852 assert!(
853 stalled.is_none() && err.contains("mira-pty-done"),
854 "child stalled at {stalled:?}: {err}\nscreen:\n{screen:?}"
855 );
856 // The frame reached the terminal, cursor-homed and erased behind itself.
857 assert!(screen.contains("\x1b[H"), "{screen:?}");
858 assert!(screen.contains("mira-pty-ready\x1b[K\r\n"), "{screen:?}");
859 // ...and the panic hook handed the terminal back: cursor on, alternate
860 // screen off. Without it the user's shell is left in raw mode.
861 assert!(trailing, "{screen:?}");
862 }
863
864 /// What [`drive`] saw: everything the child wrote to the pty, everything it
865 /// wrote to stderr, the marker it stopped answering at if it stopped at one,
866 /// and whether the trailing marker ever arrived.
867 pub(crate) struct Pty {
868 pub screen: String,
869 pub err: String,
870 pub stalled: Option<&'static str>,
871 pub trailing: bool,
872 }
873
874 /// Run one test of this binary on the far side of a real pty.
875 ///
876 /// Open a pty, re-exec this same test binary against the slave side, and
877 /// drive it from the master. The child is the same instrumented binary, so
878 /// its coverage counts; `cargo llvm-cov` merges the profraw it writes.
879 ///
880 /// This is CLAUDE.md's `script -q /dev/null` recipe with the pty opened in
881 /// process, which is what makes it a test rather than a thing to run by hand.
882 /// [`crate::tui`] drives its own event loop through here for the same reason
883 /// this module does: a loop that calls `Term::enter` needs somewhere to enter.
884 pub(crate) fn drive(
885 test: &str,
886 env: &[(&str, &std::ffi::OsStr)],
887 script: &[(&'static str, &'static [u8])],
888 trailing: &str,
889 ) -> Pty {
890 use std::os::fd::FromRawFd;
891 use std::process::{Command, Stdio};
892 use std::sync::{Arc, Mutex};
893
894 // SAFETY: flags by value, no pointer argument.
895 let master = unsafe { libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY) };
896 assert!(master >= 0, "{}", io::Error::last_os_error());
897 // SAFETY: `master` is the fd `posix_openpt` just returned, proved not
898 // -1 by the assert above and closed by nothing until `from_raw_fd`
899 // below; both calls take it by value.
900 assert_eq!(unsafe { libc::grantpt(master) }, 0);
901 // SAFETY: as above.
902 assert_eq!(unsafe { libc::unlockpt(master) }, 0);
903 // SAFETY: `ptsname` returns a NUL-terminated string in a static buffer,
904 // non-null because `master` is a pty master — `grantpt` returned 0 on
905 // it one line up, which it does not for anything else. The buffer is
906 // clobbered by the next `ptsname` on any thread, so the `CStr` is copied
907 // into an owned `String` in this same expression and never held.
908 let slave_path = unsafe { std::ffi::CStr::from_ptr(libc::ptsname(master)) }
909 .to_str()
910 .unwrap()
911 .to_owned();
912
913 let slave = std::fs::OpenOptions::new()
914 .read(true)
915 .write(true)
916 .open(&slave_path)
917 .unwrap();
918 // A size the child can assert on, so `size` is checked against something
919 // it cannot have made up. Set on the slave: macOS refuses TIOCSWINSZ on
920 // a master with no slave open yet.
921 let ws = libc::winsize {
922 ws_row: 40,
923 ws_col: 120,
924 ws_xpixel: 0,
925 ws_ypixel: 0,
926 };
927 let fd = std::os::fd::AsRawFd::as_raw_fd(&slave);
928 // SAFETY: TIOCSWINSZ is the request whose variadic argument is
929 // `*const winsize`, which is what `&ws` is, and `fd` is borrowed from
930 // `slave`, still open here — it is dropped after the spawn.
931 let sized = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ as _, &ws) };
932 assert_eq!(sized, 0, "TIOCSWINSZ: {}", io::Error::last_os_error());
933 let child = Command::new(std::env::current_exe().unwrap())
934 .args(["--exact", test, "--nocapture"])
935 .env("MIRA_PTY_CHILD", "1")
936 .envs(env.iter().copied())
937 .stdin(Stdio::from(slave.try_clone().unwrap()))
938 .stdout(Stdio::from(slave.try_clone().unwrap()))
939 // Not the pty: the child's failures have to come back over a channel
940 // the child cannot also be scribbling frames onto.
941 .stderr(Stdio::piped())
942 .spawn()
943 .unwrap();
944 drop(slave);
945
946 // SAFETY: `from_raw_fd` takes ownership, and this is the only owner
947 // `master` ever gets — it is still open (nothing has closed it since
948 // `posix_openpt`), and the child holds dups of the *slave*, not this fd.
949 // `rd` and its `try_clone` are now what will close it.
950 let mut rd = unsafe { std::fs::File::from_raw_fd(master) };
951 let mut wr = rd.try_clone().unwrap();
952 let screen = Arc::new(Mutex::new(String::new()));
953 let sink = screen.clone();
954 // Drained on a thread: a child that fills the pty buffer while this side
955 // is blocked writing to it is a deadlock, not a slow test.
956 std::thread::spawn(move || {
957 let mut buf = [0u8; 4096];
958 while let Ok(n) = rd.read(&mut buf) {
959 if n == 0 {
960 break;
961 }
962 sink.lock()
963 .unwrap()
964 .push_str(&String::from_utf8_lossy(&buf[..n]));
965 }
966 });
967
968 let arrived = |marker: &str| {
969 (0..600).any(|_| {
970 if screen.lock().unwrap().contains(marker) {
971 return true;
972 }
973 std::thread::sleep(std::time::Duration::from_millis(10));
974 false
975 })
976 };
977
978 // Every keystroke waits for the marker the child draws once it has
979 // consumed the previous one. Not politeness: a lone Esc only resolves as
980 // Esc because the 20ms grace period finds no byte behind it, so an Esc
981 // written while `x` is still in the buffer arrives as Alt+x.
982 let mut child = child;
983 let stalled = script
984 .iter()
985 .find(|(marker, keys)| !arrived(marker) || wr.write_all(keys).is_err())
986 .map(|(marker, _)| *marker);
987 // Otherwise it sits in `poll_in(-1)` for the rest of the afternoon. One
988 // expression rather than an `if`, so the healthy run — the only one CI
989 // ever takes — still executes the cleanup line.
990 let _ = stalled.map(|_| child.kill());
991
992 let out = child.wait_with_output().unwrap();
993 let trailing = arrived(trailing);
994 Pty {
995 screen: screen.lock().unwrap().clone(),
996 err: String::from_utf8_lossy(&out.stderr).into_owned(),
997 stalled,
998 trailing,
999 }
1000 }
1001
1002 /// The child of the test above. Panics are the failure channel: they land on
1003 /// the piped stderr and the exit status carries them back.
1004 fn on_the_pty() {
1005 let mut t = Term::enter().expect("stdin and stdout are the pty slave");
1006 assert_eq!(t.size(), (120, 40), "the size the parent set");
1007
1008 // A tty nobody sized — a bare `posix_openpt`, `script -q /dev/null` on
1009 // some runners — answers TIOCGWINSZ with zeros rather than failing, and
1010 // a frame laid out for zero columns is a blank screen. 80x24 instead.
1011 let sizes = [(0, 0), (40, 120)].map(|(ws_row, ws_col)| libc::winsize {
1012 ws_row,
1013 ws_col,
1014 ws_xpixel: 0,
1015 ws_ypixel: 0,
1016 });
1017 for (ws, want) in sizes.iter().zip([(80, 24), (120, 40)]) {
1018 // SAFETY: TIOCSWINSZ's variadic argument is `*const winsize`, which
1019 // is what `ws` is, and fd 1 is the pty slave — the same fd `size`
1020 // reads back through. Setting it back afterwards is what leaves the
1021 // rest of this function at the size the parent asked for.
1022 let rc = unsafe { libc::ioctl(1, libc::TIOCSWINSZ as _, ws) };
1023 assert_eq!(rc, 0, "TIOCSWINSZ: {}", io::Error::last_os_error());
1024 assert_eq!(t.size(), want, "{} columns", ws.ws_col);
1025 }
1026
1027 // Before the first marker, so the parent provably has not typed yet: the
1028 // timeout expires and says so, rather than returning a key nobody
1029 // pressed. After a marker it would be a race the parent usually wins.
1030 assert_eq!(t.key(30).unwrap(), None, "empty poll");
1031
1032 // A resize has to break an *infinite* poll, or the frame is never
1033 // repainted at the new size. Aimed at this exact thread: plain `kill`
1034 // is free to hand the signal to the test harness's thread, which is not
1035 // the one parked in `poll`.
1036 struct Tid(libc::pthread_t);
1037 // Handing a thread handle to the thread that will signal with it is the
1038 // only reason this type exists.
1039 // SAFETY: `pthread_t` is a pointer on macOS, which is the only reason
1040 // `Tid` is not `Send` already. The receiving thread does one thing with
1041 // it — `pthread_kill`, which POSIX requires to be callable from any
1042 // thread — and the handle cannot dangle, because this thread is parked
1043 // in the `t.key(-1)` below until the signal it sends arrives. Delete
1044 // that call and the id can outlive its thread.
1045 unsafe impl Send for Tid {}
1046 // SAFETY: `pthread_self` reads no memory and cannot fail.
1047 let me = Tid(unsafe { libc::pthread_self() });
1048 std::thread::spawn(move || {
1049 std::thread::sleep(std::time::Duration::from_millis(30));
1050 // SAFETY: `me.0` names the thread that is blocked in `poll` waiting
1051 // for this — see the `unsafe impl` above for why it is still alive.
1052 // SIGWINCH has a handler by now (`Term::enter` installed it), so
1053 // delivery interrupts that poll instead of killing the process.
1054 unsafe { libc::pthread_kill(me.0, libc::SIGWINCH) };
1055 });
1056 assert_eq!(t.key(-1).unwrap(), None, "a resize interrupts the poll");
1057
1058 // SIGTERM and SIGHUP are inspected rather than raised: they end the
1059 // process, and a child that dies of a signal writes no coverage profile
1060 // and reports nothing back. The failure that actually happened — no
1061 // handler at all, so a `kill` left the shell in raw mode — is visible
1062 // from the disposition. `SA_RESTART` is not asserted on: see
1063 // [`on_signal`] for why the flag has no bearing on either syscall in
1064 // this loop, and the wake-up above for the behaviour that does matter.
1065 for sig in [libc::SIGWINCH, libc::SIGTERM, libc::SIGHUP] {
1066 // SAFETY: POD and all-zero is a valid `sigaction`, as in
1067 // [`on_signal`]; here it is only a destination.
1068 let mut sa: libc::sigaction = unsafe { std::mem::zeroed() };
1069 // SAFETY: a null `act` means "report the disposition without
1070 // changing it", so the only pointer that has to be good is `&mut
1071 // sa`, a live and correctly typed `sigaction`.
1072 let got = unsafe { libc::sigaction(sig, std::ptr::null(), &mut sa) };
1073 assert_eq!(got, 0, "reading the disposition of {sig}");
1074 assert!(
1075 sa.sa_sigaction != libc::SIG_DFL && sa.sa_sigaction != libc::SIG_IGN,
1076 "signal {sig} has no handler"
1077 );
1078 }
1079
1080 // Each marker is both a frame to assert on and the parent's cue to send
1081 // the next key. See the write loop above for why they interleave.
1082 t.draw(&["mira-pty-ready".into(), "second row".into()])
1083 .unwrap();
1084 assert_eq!(t.key(-1).unwrap(), Some(Key::Down));
1085 t.draw(&["mira-pty-down".into()]).unwrap();
1086 assert_eq!(t.key(-1).unwrap(), Some(Key::Char('x')));
1087 t.draw(&["mira-pty-x".into()]).unwrap();
1088 // The lone Esc, which only resolves because the grace period ran out.
1089 assert_eq!(t.key(-1).unwrap(), Some(Key::Esc));
1090
1091 // Two shapes a real terminal will not produce on demand, injected last
1092 // because both of them break the pty this test is standing on.
1093 //
1094 // A window with no size: `size` reads it back off a *successful* ioctl,
1095 // and zero columns is what a detached or not-yet-sized tty answers.
1096 // Every column computation in `tui.rs` divides by that number.
1097 let zero = libc::winsize {
1098 ws_row: 0,
1099 ws_col: 0,
1100 ws_xpixel: 0,
1101 ws_ypixel: 0,
1102 };
1103 // SAFETY: as the parent's TIOCSWINSZ above — `*const winsize` is the
1104 // argument this request takes, and fd 1 is this process's pty slave.
1105 let sized = unsafe { libc::ioctl(1, libc::TIOCSWINSZ as _, &zero) };
1106 assert_eq!(sized, 0, "TIOCSWINSZ 0x0: {}", io::Error::last_os_error());
1107 assert_eq!(t.size(), (80, 24), "no size is 80x24, not 0x0");
1108
1109 // EOF on stdin, which `read` reports as zero bytes on a `poll` that says
1110 // there is something there. Reported as ^C because that is the only
1111 // answer that ends the draw loop — anything else spins on a descriptor
1112 // that is ready forever.
1113 let devnull = std::fs::File::open("/dev/null").unwrap();
1114 let null_fd = std::os::fd::AsRawFd::as_raw_fd(&devnull);
1115 // SAFETY: both arguments are open descriptors this process owns. `dup2`
1116 // closes fd 0 and duplicates `null_fd` onto it; nothing reads fd 0
1117 // after this but the `key` below.
1118 assert_eq!(unsafe { libc::dup2(null_fd, 0) }, 0);
1119 assert_eq!(t.key(0).unwrap(), Some(Key::Ctrl('c')), "EOF is a quit");
1120
1121 // A panic, on purpose, as the last act: the promise `enter` makes by
1122 // installing a hook is that a crash still gives you your terminal back,
1123 // and the only way to check it is to crash. The parent looks for this
1124 // exact message, so a real assertion failure above still reads as one.
1125 panic!("mira-pty-done");
1126 }
1127}