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}