Skip to main content

termlens/
error.rs

1//! Error types. The prime directive: when a wait fails in CI, the log must
2//! show what the terminal actually looked like — so timeout/EOF errors embed
3//! a full [`Screen`] snapshot and render it in their `Display` output.
4//!
5//! The same screens reach a directory when `TERMLENS_ARTIFACT_DIR` is set
6//! (#251), so a step after the tests can render them into the pull request
7//! rather than leaving them in the log; see [`Error`].
8
9use std::time::Duration;
10
11use crate::Screen;
12
13/// Convenience alias for `std::result::Result<T, termlens::Error>`.
14pub type Result<T, E = Error> = std::result::Result<T, E>;
15
16/// Errors returned by [`Terminal`](crate::Terminal) operations.
17///
18/// # `TERMLENS_ARTIFACT_DIR`
19///
20/// Every variant that carries a [`Screen`] prints it, so a CI log shows
21/// what the application displayed. When the environment variable
22/// `TERMLENS_ARTIFACT_DIR` names a directory, the same screen is also
23/// written there as it is embedded: `<test>-<n>.screen.json` with the
24/// `serde` feature, `<test>-<n>.screen.txt` (the `with_styles` rendering,
25/// which [`Screen::parse`] reads back) without, where `<test>` is the
26/// current thread's name — under `cargo test`, the test's path. Unset, the
27/// hook is one environment read and nothing else; insta's `.snap.new`
28/// files stay where insta puts them. This repository's `report` action
29/// (`.github/actions/report`) renders that directory into a pull
30/// request's step summary.
31#[derive(Debug, thiserror::Error)]
32#[non_exhaustive]
33pub enum Error {
34    /// A `wait_*` call ran past its deadline. The screen at the moment of
35    /// the timeout is embedded and printed, so a CI log alone is enough to
36    /// see what the application was actually showing.
37    #[error(
38        "timed out after {timeout:?} while waiting for {waiting_for}\n\
39         --- screen at timeout ---\n{screen}"
40    )]
41    Timeout {
42        /// Human description of what was awaited.
43        waiting_for: String,
44        /// The deadline that expired.
45        timeout: Duration,
46        /// The screen when the deadline expired.
47        screen: Screen,
48    },
49
50    /// The PTY reached end-of-file (the child exited or closed its
51    /// terminal) while a wait's condition was still unmet. Waiting longer
52    /// can never succeed, so this fails fast instead of burning the full
53    /// timeout.
54    #[error(
55        "terminal closed (EOF) while waiting for {waiting_for}\n\
56         --- final screen ---\n{screen}"
57    )]
58    Eof {
59        /// Human description of what was awaited.
60        waiting_for: String,
61        /// The final screen contents.
62        screen: Screen,
63    },
64
65    /// Spawning the child process failed.
66    #[error("failed to spawn `{command}`: {reason}")]
67    Spawn {
68        /// The command line that failed to spawn.
69        command: String,
70        /// The underlying PTY/OS error.
71        reason: String,
72    },
73
74    /// A PTY control operation (open, resize, reader/writer setup) failed.
75    #[error("PTY error: {0}")]
76    Pty(String),
77
78    /// An OS-level I/O error (e.g. while waiting on the child process).
79    #[error("i/o error: {0}")]
80    Io(#[from] std::io::Error),
81
82    /// A terminal size argument is invalid and was rejected before anything
83    /// was spawned or sent to the child.
84    #[error("invalid terminal size: {0}")]
85    Size(String),
86
87    /// The VT emulator panicked while processing the child's output, so the
88    /// grid stopped advancing at the screen embedded here.
89    ///
90    /// The emulation runs on the reader thread, where a panic propagates
91    /// nowhere: before this existed the drain simply died, every later
92    /// snapshot returned the same frozen screen, and each wait ran to its
93    /// deadline reporting a predicate that was never going to become true.
94    /// A wait now fails immediately and says why. The screen is the last one
95    /// taken before the failure — the emulator is not asked again, because
96    /// its state after a panic means nothing.
97    #[error(
98        "the terminal emulator failed and the screen stopped advancing: {detail}\n\
99         --- last screen before the failure ---\n{screen}"
100    )]
101    Emulator {
102        /// The panic message from the emulator.
103        detail: String,
104        /// The last screen taken before the emulator failed.
105        screen: Screen,
106    },
107
108    /// Typed input or control the child cannot receive — e.g. a mouse
109    /// click while the application never enabled mouse tracking (sending
110    /// it anyway would feed the app bytes it would misparse as garbage
111    /// keys), or a signal to a child that has already been reaped (its
112    /// pid may belong to someone else by now).
113    #[error("input not receivable: {0}")]
114    Input(String),
115
116    /// A saved screen could not be read back by [`Screen::parse`]: the text
117    /// is not the snapshot format of `docs/DESIGN.md` §3. The message names
118    /// the line.
119    #[error("could not parse a saved screen: {0}")]
120    Parse(String),
121
122    /// Typed input could not be delivered: the child is gone and the OS
123    /// tore the terminal down, or it stopped reading its input and the
124    /// write gave up at the terminal's deadline rather than blocking
125    /// forever.
126    ///
127    /// Distinct from [`Error::Input`] on purpose. `Input` means the
128    /// application cannot make sense of these bytes — a test bug. This
129    /// means the bytes could not be handed over at all, which is a fact
130    /// about the child rather than about the test, so the screen at the
131    /// moment of the failure is embedded the way a timeout's is.
132    #[error("failed to send {what}\n--- screen at the failed write ---\n{screen}")]
133    Write {
134        /// What was being sent, which command it was going to, and why the
135        /// write failed.
136        what: Box<str>,
137        /// The screen when the write failed.
138        screen: Screen,
139    },
140}
141
142impl Error {
143    /// The screen embedded in [`Error::Timeout`], [`Error::Eof`],
144    /// [`Error::Emulator`] or [`Error::Write`], if any.
145    #[must_use]
146    pub fn screen(&self) -> Option<&Screen> {
147        match self {
148            Error::Timeout { screen, .. }
149            | Error::Eof { screen, .. }
150            | Error::Emulator { screen, .. }
151            | Error::Write { screen, .. } => Some(screen),
152            _ => None,
153        }
154    }
155
156    /// The `TERMLENS_ARTIFACT_DIR` hook (#251): every error that carries a
157    /// screen passes through here on its way out of the crate, and when the
158    /// variable is set the screen is also written to that directory. The
159    /// call is a no-op when it is not — the common case, and the reason
160    /// the check is one environment read.
161    pub(crate) fn recorded(self) -> Self {
162        if let Some(screen) = self.screen() {
163            artifact::write(screen);
164        }
165        self
166    }
167}
168
169/// The `TERMLENS_ARTIFACT_DIR` hook. A CI log shows the screen a failing
170/// wait embedded; this puts the same screen somewhere a step after the
171/// tests can pick it up — the `report` action in this repository renders
172/// each into the pull request's step summary.
173pub(crate) mod artifact {
174    use std::path::PathBuf;
175    use std::sync::atomic::{AtomicUsize, Ordering};
176
177    use crate::Screen;
178
179    /// The environment variable naming the directory. Unset means off.
180    pub(crate) const VAR: &str = "TERMLENS_ARTIFACT_DIR";
181
182    /// One counter per test process, so two screens from one test are two
183    /// files rather than one overwritten.
184    static COUNTER: AtomicUsize = AtomicUsize::new(0);
185
186    /// Write `screen` to `$TERMLENS_ARTIFACT_DIR/<test>-<n>.screen.json`
187    /// (with the `serde` feature) or `.screen.txt` (the `with_styles`
188    /// rendering, which `Screen::parse` reads back). `<test>` is the
189    /// current thread's name, which under `cargo test` is the test's path.
190    /// Best effort: a directory that cannot be written is reported once on
191    /// stderr, and the error the screen came from is returned regardless.
192    pub(crate) fn write(screen: &Screen) {
193        let Some(dir) = std::env::var_os(VAR).filter(|d| !d.is_empty()) else {
194            return;
195        };
196        let dir = PathBuf::from(dir);
197        let n = COUNTER.fetch_add(1, Ordering::Relaxed) + 1;
198        let thread = std::thread::current();
199        let test: String = thread
200            .name()
201            .unwrap_or("screen")
202            .chars()
203            .map(|c| {
204                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
205                    c
206                } else {
207                    '_'
208                }
209            })
210            .collect();
211        let (name, body) = render(screen, &format!("{test}-{n}"));
212        let path = dir.join(name);
213        if let Err(e) = std::fs::create_dir_all(&dir).and_then(|()| std::fs::write(&path, body)) {
214            eprintln!("termlens: could not write {} ({VAR}): {e}", path.display());
215        }
216    }
217
218    #[cfg(feature = "serde")]
219    fn render(screen: &Screen, stem: &str) -> (String, String) {
220        let json =
221            serde_json::to_string(screen).unwrap_or_else(|_| screen.with_styles().to_string());
222        (format!("{stem}.screen.json"), json)
223    }
224
225    #[cfg(not(feature = "serde"))]
226    fn render(screen: &Screen, stem: &str) -> (String, String) {
227        (
228            format!("{stem}.screen.txt"),
229            screen.with_styles().to_string(),
230        )
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::screen::{Cell, Style, TermState};
238
239    fn tiny_screen() -> Screen {
240        let mut cells = Vec::new();
241        for ch in ['o', 'k'] {
242            cells.push(Cell::new(ch.to_string(), Style::default(), false, false));
243        }
244        cells.push(Cell::new(String::new(), Style::default(), false, false));
245        Screen::from_parts(3, 1, 0, 2, true, cells, TermState::default())
246    }
247
248    #[test]
249    fn timeout_display_embeds_screen_dump() {
250        let err = Error::Timeout {
251            waiting_for: "text \"ready\"".into(),
252            timeout: Duration::from_millis(250),
253            screen: tiny_screen(),
254        };
255        let msg = err.to_string();
256        assert!(msg.contains("timed out after 250ms"), "{msg}");
257        assert!(msg.contains("--- screen at timeout ---"), "{msg}");
258        assert!(msg.contains("size: 3x1  cursor: 0,2"), "{msg}");
259        assert!(msg.contains("\nok"), "{msg}");
260        assert_eq!(err.screen().unwrap().size(), (3, 1));
261    }
262}