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}