Skip to main content

mecha10_cli/dev/
termination.rs

1//! Shared termination/intent model for `mecha10 dev`'s interactive TUI screens.
2//!
3//! Before this module existed, "what does this key mean" was answered independently by every
4//! screen that owned terminal input: the outer dev-mode loop (`ops/interactive.rs`) had its own
5//! `is_dev_quit_key`, the teleop sub-screen (`ui/teleop.rs`) had its own `is_quit_all_key` plus a
6//! separate zero-velocity `x`/Space branch, and three more call sites each registered their own
7//! `ctrlc::set_handler`. Because those handlers didn't compose, the same physical key (`x`) meant
8//! two unrelated things depending on which screen happened to own input at the time: "stop the
9//! simulation" at the outer dev-mode menu, or "zero velocity, stay put" inside teleop. A user who
10//! pressed `s` (enter simulation, which auto-opens teleop) then `x` got the second meaning and
11//! nothing stopped - the reported bug this module fixes.
12//!
13//! `classify_key` gives every screen the same answer for "what does this key mean", and
14//! `ScreenOutcome` lets a nested screen (teleop) report what happened without needing to know
15//! about the outer loop's own shutdown flag - the translation from "screen backed out" to "what
16//! that means for this caller" belongs to the caller (see `ops/teleop.rs::enter_teleop_mode`),
17//! not to the screen itself.
18//!
19//! `install_quit_signal` replaces the four independent, copy-pasted `ctrlc::set_handler`
20//! registrations (`ops/interactive.rs`, `ops/lifecycle.rs::run_watch_mode`,
21//! `handlers/logs.rs::follow_logs`, plus what used to be `ui/teleop.rs`'s own Ctrl+C handling
22//! before it was folded into `classify_key`) with one shared helper.
23
24use anyhow::Result;
25use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::{Arc, Mutex, OnceLock};
28
29/// What a termination-shaped key ("stop the current thing" or "quit everything") means.
30///
31/// Deliberately does not cover Space - Space is "zero velocity, stay in teleop", a distinct
32/// non-terminating safety control, not a termination concept. It never goes through
33/// `classify_key` at all; see `ui/teleop.rs::handle_key`.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TerminationIntent {
36    /// Stop whatever the current active mode/screen is driving, and return to its parent
37    /// context. Bare `q`, `Esc`, or bare `x` (no modifiers).
38    StopCurrent,
39    /// Tear down the entire `mecha10 dev` session - every running node, every nested screen.
40    /// Ctrl+C or Ctrl+X.
41    QuitAll,
42}
43
44/// Classify a key event into a `TerminationIntent`, if it is one. Pure, dependency-free function
45/// so it's unit testable without a live terminal/event loop.
46///
47/// - Bare `q` / `Esc` / bare `x` (no modifiers) -> `StopCurrent`.
48/// - Ctrl+C / Ctrl+X (any other modifiers alongside Control, e.g. Shift from a capital-C keycap,
49///   still match - `contains` rather than exact equality) -> `QuitAll`.
50/// - Anything else (including Space, and `q`/`x` with unrelated modifiers) -> `None`.
51pub fn classify_key(key: &KeyEvent) -> Option<TerminationIntent> {
52    if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('x')) {
53        return Some(TerminationIntent::QuitAll);
54    }
55
56    if key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('x')) {
57        return Some(TerminationIntent::StopCurrent);
58    }
59
60    None
61}
62
63/// What a nested screen's event loop did, for its caller to interpret. A screen that owns its own
64/// sub-context (e.g. `TeleopUi`) should never mutate the outer loop's shutdown flag directly - it
65/// reports what happened here, and lets the caller (which knows how it was entered) decide what
66/// that means. See `ops/teleop.rs::enter_teleop_mode`, which interprets `ReturnToParent`
67/// differently depending on whether it was entered via `'s'` (simulation hand-off, so backing out
68/// means "stop the simulation and return to the dev-mode menu") or `'t'` (standalone teleop, so
69/// backing out just closes the screen).
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum ScreenOutcome {
72    /// `TerminationIntent::StopCurrent` fired, or the screen's own back-out (e.g. `q`/Esc)
73    /// completed normally.
74    ReturnToParent,
75    /// `TerminationIntent::QuitAll` fired - propagate outward unswallowed, all the way to the
76    /// top-level `running` flag, so the outer loop's own `if !running.load() { break }` handles
77    /// full session teardown.
78    QuitAll,
79    /// Nothing terminal happened; keep running the screen's event loop.
80    Continue,
81}
82
83/// Guards against `ctrlc::set_handler`'s "can only be installed once per process" restriction.
84/// Safe here because `install_quit_signal`'s call sites are mutually exclusive invocation paths
85/// within one process lifetime (e.g. `mecha10 dev` vs `mecha10 dev --watch` vs `mecha10 logs -f`
86/// never run concurrently in the same process) - this is a defensive guard, not a concurrency
87/// mechanism.
88static QUIT_SIGNAL_INSTALLED: OnceLock<Arc<AtomicBool>> = OnceLock::new();
89
90/// Serializes the check-then-register sequence in `install_quit_signal` so two concurrent
91/// callers (e.g. tests running in parallel within the same process) can't both observe
92/// `QUIT_SIGNAL_INSTALLED` as empty and both call `ctrlc::set_handler`, which errors on its
93/// second invocation. Without this, `install_quit_signal` had a TOCTOU race: the read of
94/// `QUIT_SIGNAL_INSTALLED.get()` and the write via `get_or_init` weren't atomic with the
95/// `ctrlc::set_handler` call in between, so the loser of the race returned an error instead of
96/// the winner's shared flag.
97static QUIT_SIGNAL_INIT_LOCK: Mutex<()> = Mutex::new(());
98
99/// Install a single process-wide Ctrl+C handler and return the shared "keep running" flag it
100/// clears on signal. Replaces the four independent, copy-pasted `ctrlc::set_handler`
101/// registrations that used to exist across `ops/interactive.rs`, `ops/lifecycle.rs`, and
102/// `handlers/logs.rs`.
103///
104/// Calling this more than once within a process is safe: only the first call actually registers
105/// the OS-level handler; subsequent (and concurrent) calls just return the same shared
106/// `AtomicBool`, since `ctrlc::set_handler` itself can only be installed once and would error on
107/// a second registration attempt. The registration itself is serialized by
108/// `QUIT_SIGNAL_INIT_LOCK` so concurrent first-callers can't race each other into that error.
109pub fn install_quit_signal() -> Result<Arc<AtomicBool>> {
110    if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
111        return Ok(existing.clone());
112    }
113
114    let _guard = QUIT_SIGNAL_INIT_LOCK
115        .lock()
116        .unwrap_or_else(|poisoned| poisoned.into_inner());
117
118    if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
119        return Ok(existing.clone());
120    }
121
122    let running = Arc::new(AtomicBool::new(true));
123    let r = running.clone();
124    ctrlc::set_handler(move || {
125        r.store(false, Ordering::SeqCst);
126    })?;
127
128    Ok(QUIT_SIGNAL_INSTALLED.get_or_init(|| running).clone())
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crossterm::event::KeyEvent;
135
136    #[test]
137    fn plain_q_is_stop_current() {
138        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
139        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
140    }
141
142    #[test]
143    fn esc_is_stop_current() {
144        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
145        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
146    }
147
148    #[test]
149    fn plain_x_without_control_modifier_is_stop_current() {
150        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
151        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
152    }
153
154    #[test]
155    fn ctrl_c_is_quit_all() {
156        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
157        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
158    }
159
160    #[test]
161    fn ctrl_x_is_quit_all() {
162        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
163        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
164    }
165
166    #[test]
167    fn ctrl_shift_c_is_still_quit_all() {
168        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
169        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
170    }
171
172    #[test]
173    fn space_is_not_a_termination_key() {
174        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
175        assert_eq!(classify_key(&key), None);
176    }
177
178    #[test]
179    fn plain_c_without_control_modifier_is_not_a_termination_key() {
180        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
181        assert_eq!(classify_key(&key), None);
182    }
183
184    #[test]
185    fn ctrl_q_is_not_a_termination_key() {
186        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
187        assert_eq!(classify_key(&key), None);
188    }
189
190    #[test]
191    fn unrelated_key_is_not_a_termination_key() {
192        let key = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE);
193        assert_eq!(classify_key(&key), None);
194    }
195
196    #[test]
197    fn install_quit_signal_returns_a_flag_initially_true() {
198        let running = install_quit_signal().expect("installing the quit signal must succeed");
199        assert!(running.load(Ordering::SeqCst));
200    }
201
202    #[test]
203    fn install_quit_signal_is_idempotent_across_repeated_calls() {
204        let first = install_quit_signal().expect("first install must succeed");
205        let second = install_quit_signal().expect("second install must not error");
206        assert!(Arc::ptr_eq(&first, &second));
207    }
208
209    #[test]
210    fn install_quit_signal_survives_concurrent_first_callers() {
211        let handles: Vec<_> = (0..16).map(|_| std::thread::spawn(install_quit_signal)).collect();
212
213        let flags: Vec<Arc<AtomicBool>> = handles
214            .into_iter()
215            .map(|handle| {
216                handle
217                    .join()
218                    .expect("thread must not panic")
219                    .expect("install_quit_signal must not error")
220            })
221            .collect();
222
223        let first = &flags[0];
224        for flag in &flags[1..] {
225            assert!(
226                Arc::ptr_eq(first, flag),
227                "all concurrent callers must observe the same shared flag"
228            );
229        }
230    }
231}