Skip to main content

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}