Skip to main content

degenbot_cli/
signal.rs

1//! SIGINT ownership (ADR-051 D7).
2//!
3//! The console owns the interrupt policy on BOTH entry paths. cli-core only
4//! carries the cooperative [`CancelHandle`] the updater loops poll at chunk
5//! boundaries (never mid-chunk, so chunk atomicity is preserved); this module
6//! turns Ctrl+C into that handle:
7//!
8//! - **first Ctrl+C** -> [`Action::Cancel`]: set the handle. The in-flight
9//!   chunk completes (commit OR rollback), the run returns a friendly
10//!   `cancelled` report, and committed chunks stay durable.
11//! - **second Ctrl+C** -> [`Action::Abort`]: abort the process.
12//!
13//! The listener is ONE dedicated std thread running a current-thread runtime.
14//! A one-shot console command must never boot the shared bot runtimes
15//! (`degenbot_core::runtime`) for a single parked signal task, so this surface
16//! is fully self-owned (asserted by `tests/sigint_runtime_isolation.rs`). The
17//! command itself stays synchronous: the listener parks on the signal stream
18//! and never blocks the calling thread.
19
20use degenbot_cli_core::CancelHandle;
21
22/// What a received interrupt means.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[must_use]
25pub enum Action {
26    /// Feed the cooperative cancel flag.
27    Cancel,
28    /// Restore the default disposition and abort.
29    Abort,
30}
31
32/// The policy: the first interrupt cancels, the second aborts.
33pub const fn action(already_cancelled: bool) -> Action {
34    if already_cancelled {
35        Action::Abort
36    } else {
37        Action::Cancel
38    }
39}
40
41/// Keeps the SIGINT listener alive for the lifetime of the run; dropping the
42/// guard aborts the listener task, which drops its signal stream and restores
43/// the default SIGINT disposition (the policy is run-scoped).
44#[derive(Debug)]
45#[must_use = "the guard must outlive the command run"]
46pub struct Guard {
47    /// Aborting the task parks the single listener thread at its next
48    /// `.await`, which returns from `block_on`, drops the runtime, and ends
49    /// the thread.
50    listener: Option<tokio::task::AbortHandle>,
51}
52
53impl Drop for Guard {
54    fn drop(&mut self) {
55        // The abort is synchronous + non-blocking (safe from any thread): the
56        // task cancels at its next `.await` park.
57        if let Some(listener) = self.listener.take() {
58            listener.abort();
59        }
60    }
61}
62
63/// The census entry for the dedicated listener thread. The registry is
64/// documentation-enforced for every spawn site (see `worker_census` module
65/// docs): an unregistered thread is invisible to the gauge and the boot dump.
66#[cfg(unix)]
67const fn census_entry() -> degenbot_core::worker_census::WorkerCensusEntry {
68    degenbot_core::worker_census::WorkerCensusEntry {
69        resource: "cli_sigint_listener",
70        kind: "std signal-listener thread (one current-thread runtime; SIGINT -> cooperative CancelHandle)",
71        count: 1,
72        thread_name: "degenbot-cli-sigint",
73        sizing: "exactly one per run (fixed; owned by the run guard)",
74        binding: "pinned",
75    }
76}
77
78/// Install the SIGINT policy for `cancel`.
79///
80/// Spawns the listener on its OWN current-thread runtime — never the
81/// process-wide shared runtime (`degenbot_core::runtime::get_runtime`), which
82/// a console invocation must not pay for. A runtime- or thread-spawn failure
83/// is handled at this site (a warning is logged): cooperative cancel is then
84/// unavailable, but the run still proceeds.
85#[cfg(unix)]
86pub fn install(cancel: CancelHandle) -> Guard {
87    degenbot_core::worker_census::register(census_entry());
88
89    // Built on the calling thread (a Runtime is Send), then handed to the
90    // one listener thread. A current_thread runtime suffices: the listener is
91    // a single task parking on the signal stream.
92    let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
93        .enable_all()
94        .build()
95    else {
96        degenbot_core::op_warn!(
97            domain = pump,
98            "could not build the SIGINT listener runtime; cooperative cancel is unavailable"
99        );
100        return Guard { listener: None };
101    };
102
103    // The abort handle crosses back to the guard; the listen task stays with
104    // the runtime that spawned it.
105    let (tx, rx) = std::sync::mpsc::channel();
106    let spawned = std::thread::Builder::new()
107        .name("degenbot-cli-sigint".to_owned())
108        .spawn(move || {
109            let task = runtime.spawn(listen(cancel));
110            let _ = tx.send(task.abort_handle());
111            let _ = runtime.block_on(task);
112        });
113
114    if let Err(error) = spawned {
115        degenbot_core::op_warn!(
116            domain = pump,
117            error = %error,
118            "could not spawn the SIGINT listener thread; cooperative cancel is unavailable"
119        );
120        return Guard { listener: None };
121    }
122    let listener = rx
123        .recv()
124        .map_err(|_| {
125            degenbot_core::op_warn!(
126                domain = pump,
127                "SIGINT listener thread died before installing; cooperative cancel is unavailable"
128            );
129        })
130        .ok();
131    Guard { listener }
132}
133
134/// Non-Unix builds have no SIGINT policy to install.
135#[cfg(not(unix))]
136pub fn install(_cancel: CancelHandle) -> Guard {
137    degenbot_core::op_warn!(
138        domain = pump,
139        "SIGINT handling is Unix-only; cooperative cancel is unavailable"
140    );
141    Guard { listener: None }
142}
143
144#[cfg(unix)]
145async fn listen(cancel: CancelHandle) {
146    let Ok(mut interrupts) =
147        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
148    else {
149        degenbot_core::op_warn!(
150            domain = pump,
151            "could not install the SIGINT handler; cooperative cancel is unavailable"
152        );
153        return;
154    };
155    loop {
156        if interrupts.recv().await.is_none() {
157            return;
158        }
159        match action(cancel.is_cancelled()) {
160            Action::Cancel => {
161                cancel.cancel();
162                degenbot_core::op_warn!(
163                    domain = pump,
164                    "interrupt received: finishing the in-flight chunk, then stopping (press Ctrl+C again to abort)"
165                );
166            }
167            Action::Abort => {
168                // Abort is the outcome D7 names; there is no subsequent
169                // disposition to restore because the process dies here. The
170                // default backtrace-less abort skips destructors, which is the
171                // point: the run is already unwinding under a cooperative stop.
172                degenbot_core::op_warn!(domain = pump, "second interrupt received: aborting");
173                std::process::abort();
174            }
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::{action, Action};
182
183    #[test]
184    fn first_interrupt_cancels_and_second_aborts() {
185        assert_eq!(action(false), Action::Cancel);
186        assert_eq!(action(true), Action::Abort);
187    }
188}