mdtask_core/cancel.rs
1//! Stopping a run that is already going.
2//!
3//! This exists for the agent surface. A person at a terminal has Ctrl-C; an MCP
4//! client has only `notifications/cancelled`, and something has to be listening
5//! for it and able to act. Without this, a task that waits on a network call, or
6//! serves, or simply sleeps, runs to completion no matter what the client says.
7//!
8//! # Killing the group, not the child
9//!
10//! A shell task is `sh -c <script>`, so the thing we spawn is a shell, and the
11//! work is its children. Killing the shell alone leaves `sleep 1000` (or a
12//! `docker compose up`) orphaned and running, while we report the task stopped.
13//! That is worse than not cancelling at all, because it is a lie.
14//!
15//! So each step is spawned into its **own process group** and the signal goes to
16//! the group. `CommandExt::process_group` sets it with no dependency;
17//! `killpg` needs `libc`, which is why this crate has one on unix.
18//!
19//! # Terminate, then kill
20//!
21//! `cancel` sends `SIGTERM` so a script's `trap` can clean up, then escalates to
22//! `SIGKILL` after a grace period. The escalation runs on its own thread: the
23//! caller is a request loop that has to stay responsive, and blocking it for the
24//! grace period would recreate the exact problem this is here to solve.
25
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::{Arc, Mutex};
28
29/// How long a cancelled step gets to handle `SIGTERM` before `SIGKILL`.
30///
31/// Long enough for a `trap` to remove a temporary file, short enough that a
32/// client asking to cancel does not conclude nothing happened.
33#[cfg(unix)]
34const GRACE: std::time::Duration = std::time::Duration::from_secs(2);
35
36/// A handle for stopping a run from another thread.
37///
38/// Cheap to clone (one `Arc`), which is the point: the thread running the task
39/// holds one, and so does whatever is listening for the request to stop.
40#[derive(Clone, Default)]
41pub struct Cancel {
42 inner: Arc<Inner>,
43}
44
45#[derive(Default)]
46struct Inner {
47 cancelled: AtomicBool,
48 /// The process group of the step running right now, if one is.
49 group: Mutex<Option<u32>>,
50}
51
52impl Cancel {
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 /// Stop the run: signal whatever is running now, and refuse to start any
58 /// further step.
59 ///
60 /// Idempotent, and safe to call when nothing is running (a run cancelled
61 /// between steps simply never starts the next one).
62 pub fn cancel(&self) {
63 self.inner.cancelled.store(true, Ordering::SeqCst);
64 let group = *self.inner.group.lock().expect("cancel mutex");
65 if let Some(pgid) = group {
66 self.signal_group(pgid);
67 }
68 }
69
70 /// Whether [`cancel`](Self::cancel) has been called.
71 pub fn is_cancelled(&self) -> bool {
72 self.inner.cancelled.load(Ordering::SeqCst)
73 }
74
75 /// Record the group of a step that has just started, and signal it at once
76 /// if cancellation arrived while it was being spawned.
77 ///
78 /// The check happens under the lock, which is what closes that race: a
79 /// `cancel` landing between `spawn` and here would otherwise find no group
80 /// recorded and signal nothing, leaving the step running after a successful
81 /// cancellation.
82 pub(crate) fn entered(&self, pgid: u32) {
83 let mut group = self.inner.group.lock().expect("cancel mutex");
84 *group = Some(pgid);
85 drop(group);
86 if self.is_cancelled() {
87 self.signal_group(pgid);
88 }
89 }
90
91 /// Forget the step's group, because it has exited. Also what stops the
92 /// escalation thread from signalling a group id the system has since reused.
93 pub(crate) fn left(&self) {
94 *self.inner.group.lock().expect("cancel mutex") = None;
95 }
96
97 #[cfg(unix)]
98 fn signal_group(&self, pgid: u32) {
99 // Negative pid means "the group". ESRCH (already gone) is the expected
100 // outcome of a race and is not worth reporting.
101 unsafe { libc::killpg(pgid as libc::pid_t, libc::SIGTERM) };
102
103 // Escalate on a thread so the caller's loop keeps serving. The clone is
104 // an Arc bump, not a copy of anything.
105 let inner = Arc::clone(&self.inner);
106 std::thread::spawn(move || {
107 std::thread::sleep(GRACE);
108 // Only if this exact step is still running. Without the check, a
109 // step that exited during the grace period could have had its group
110 // id reused, and we would SIGKILL an unrelated process.
111 let still = *inner.group.lock().expect("cancel mutex");
112 if still == Some(pgid) {
113 unsafe { libc::killpg(pgid as libc::pid_t, libc::SIGKILL) };
114 }
115 });
116 }
117
118 /// Without process groups there is nothing to signal, so cancellation takes
119 /// effect at the next step boundary. mdtask's tasks are shell scripts, so
120 /// this platform is already a long way off the beaten path.
121 #[cfg(not(unix))]
122 fn signal_group(&self, _pgid: u32) {}
123}
124
125impl std::fmt::Debug for Cancel {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 f.debug_struct("Cancel")
128 .field("cancelled", &self.is_cancelled())
129 .finish()
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn a_fresh_handle_is_not_cancelled() {
139 assert!(!Cancel::new().is_cancelled());
140 }
141
142 #[test]
143 fn cancelling_is_visible_through_a_clone() {
144 let a = Cancel::new();
145 let b = a.clone();
146 a.cancel();
147 assert!(b.is_cancelled(), "the clone shares the state");
148 }
149
150 #[test]
151 fn cancelling_twice_is_harmless() {
152 let c = Cancel::new();
153 c.cancel();
154 c.cancel();
155 assert!(c.is_cancelled());
156 }
157
158 /// Cancelling with nothing running must not panic or block: a run cancelled
159 /// between two steps simply never starts the next one.
160 #[test]
161 fn cancelling_with_nothing_running_is_fine() {
162 let c = Cancel::new();
163 c.cancel();
164 c.left();
165 assert!(c.is_cancelled());
166 }
167}