Skip to main content

hotl_platform/console/
mod.rs

1//! [`ConsoleControl`] — save/restore terminal state, and trap the
2//! interrupt-class events that would otherwise skip every destructor.
3
4use std::io;
5
6#[cfg(unix)]
7mod unix;
8#[cfg(unix)]
9pub use unix::{UnixConsoleControl, UnixModes};
10#[cfg(unix)]
11pub type ActiveConsoleControl = UnixConsoleControl;
12
13#[cfg(windows)]
14mod windows;
15#[cfg(windows)]
16pub use windows::{WindowsConsoleControl, WindowsModes};
17#[cfg(windows)]
18pub type ActiveConsoleControl = WindowsConsoleControl;
19
20/// What an implementor's interrupt handler may do.
21///
22/// This is part of the contract, not trivia: a handler that is legal on one OS
23/// is undefined behavior on the other, and a caller cannot write one correctly
24/// without knowing which it has.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum HandlerContract {
27    /// The handler runs on an arbitrary thread inside a signal context. **No
28    /// allocation, no locks, no non-reentrant libc.** Anything else is UB.
29    AsyncSignalSafe,
30    /// `SetConsoleCtrlHandler` runs the handler on a thread of its own, and the
31    /// process is killed if it has not returned within roughly this budget for
32    /// `CTRL_CLOSE_EVENT`. It **may** allocate and lock.
33    SeparateThreadWithBudget { millis: u32 },
34}
35
36/// Save/restore terminal state and trap the interrupt-class events.
37pub trait ConsoleControl: crate::sealed::Sealed {
38    /// The saved modes, held by the *caller* — which is what lets the caller
39    /// keep them in a `OnceLock` rather than a leaked `AtomicPtr`.
40    type Saved: Send + Sync + Copy + 'static;
41
42    const HANDLER_CONTRACT: HandlerContract;
43
44    /// Capture the modes as they are *now*. Call before anything enters raw
45    /// mode: a later call would save raw modes as the thing to restore to.
46    fn capture(&self) -> io::Result<Self::Saved>;
47
48    /// Put the modes back. Must be callable from the interrupt handler, so on
49    /// Unix this is `tcsetattr` and nothing else.
50    fn restore(&self, saved: &Self::Saved) -> io::Result<()>;
51
52    /// Write bytes straight to the terminal, bypassing any buffering — the
53    /// escape-sequence half of a restore. Async-signal-safe on Unix.
54    fn write_raw(&self, bytes: &[u8]);
55
56    /// Run `on_interrupt` for each interrupt-class event, then exit.
57    ///
58    /// `on_interrupt` must respect [`HANDLER_CONTRACT`](ConsoleControl::HANDLER_CONTRACT).
59    /// A plain `fn` rather than a closure so there is nothing to allocate or
60    /// capture on the Unix path.
61    fn trap(&self, on_interrupt: fn()) -> io::Result<()>;
62
63    /// The process exit code for "killed by an interrupt".
64    ///
65    /// `128 + signal` is a POSIX **shell** convention with no Windows meaning.
66    /// Windows returns 130 for Ctrl-C anyway, because scripts and CI check for
67    /// it — a deliberate borrowing, flagged here rather than left to look like
68    /// an accident.
69    fn interrupt_exit_code(&self) -> i32;
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    /// Capture and restore round-trip, on whatever this build selected.
77    ///
78    /// Deliberately does *not* assert a terminal exists: under a test harness
79    /// stdin may or may not be one, and a test whose verdict depends on how it
80    /// was invoked is worse than no test. What is asserted is the rule that
81    /// holds either way — an adapter that cannot capture reports the OS's own
82    /// error rather than inventing one.
83    ///
84    /// `raw_os_error()` is the discriminator, not `kind()`: a real errno always
85    /// carries one, and a synthesized `Error::new(Unsupported, "…")` never
86    /// does. Testing `kind()` was the first version of this and it was wrong —
87    /// errno-to-kind mapping is a std implementation detail that can land on
88    /// `Unsupported` legitimately, which made the test fail whenever stdin was
89    /// not a tty.
90    #[test]
91    fn a_console_that_cannot_be_captured_reports_the_os_error_rather_than_inventing_one() {
92        let ctl = crate::CONSOLE;
93        match ctl.capture() {
94            Ok(saved) => ctl.restore(&saved).expect("captured modes must restore"),
95            Err(e) => assert!(
96                e.raw_os_error().is_some(),
97                "the failure must carry a real errno, not a synthesized one: {e:?}"
98            ),
99        }
100    }
101
102    #[test]
103    fn the_interrupt_exit_code_is_the_shell_convention() {
104        // 130 = 128 + SIGINT, on both platforms and for the same reason: it is
105        // what scripts and CI check for.
106        assert_eq!(crate::CONSOLE.interrupt_exit_code(), 130);
107    }
108}