Skip to main content

mermaid_cli/effect/
turn_scope.rs

1//! Per-turn structured concurrency.
2//!
3//! A `TurnScope` owns exactly one `CancellationToken` and one
4//! `JoinSet`. Every task spawned for this turn gets a clone of the
5//! token, and every handle lands in the set. When the user cancels
6//! (or the reducer dispatches `Cmd::CancelScope`), we cancel the
7//! token — tokio's cooperative cancellation then unwinds every child
8//! at its next `.await`. The set is drained on drop so no task leaks.
9//!
10//! The point: cancellation is a **signal**, not a **poll**. No child
11//! task has to remember to check a shared flag; they're awaiting an
12//! mpsc receive or an HTTP body, and `token.cancelled()` races every
13//! such await via `select!`. Abort latency = the time to reach the
14//! next await point — microseconds for HTTP streams, milliseconds for
15//! tool subprocess fan-out.
16//!
17//! Forgetting cancellation is impossible: the token is baked into
18//! every adapter's `StreamContext` / `ExecContext`, and the adapter
19//! must `select!` on it to proceed. Contrast with a "drain events
20//! every 50ms" polling pattern, where long-running ops (web search,
21//! execute command) had to remember to check a shared flag — silent
22//! forgetting there shipped as hangs-until-timeout bugs.
23
24use std::future::Future;
25
26use tokio::task::{AbortHandle, JoinSet};
27use tokio_util::sync::CancellationToken;
28
29use crate::domain::TurnId;
30
31/// One turn's cancellable scope. Construct once per `SubmitPrompt`;
32/// abandon (drop) at the end of the turn.
33#[derive(Debug)]
34pub struct TurnScope {
35    id: TurnId,
36    token: CancellationToken,
37    /// A second signal, parallel to `token`, meaning "background the running
38    /// work, don't kill it" (Ctrl+B). Tools that can detach (execute_command)
39    /// `select!` on it; everyone else ignores it.
40    background: CancellationToken,
41    joins: JoinSet<()>,
42}
43
44impl TurnScope {
45    pub fn new(id: TurnId) -> Self {
46        Self {
47            id,
48            token: CancellationToken::new(),
49            background: CancellationToken::new(),
50            joins: JoinSet::new(),
51        }
52    }
53
54    pub fn id(&self) -> TurnId {
55        self.id
56    }
57
58    /// Clone the scope's token. Hand this to child tasks so they can
59    /// participate in cooperative cancellation.
60    pub fn token(&self) -> CancellationToken {
61        self.token.clone()
62    }
63
64    /// Clone the scope's background-request token (Ctrl+B). Tools that can
65    /// detach a running child select on this instead of killing it.
66    pub fn background_token(&self) -> CancellationToken {
67        self.background.clone()
68    }
69
70    /// Signal "background the running work" to every child task that listens.
71    pub fn background(&self) {
72        self.background.cancel();
73    }
74
75    /// Spawn a child task under this scope. The returned handle is
76    /// retained inside the scope's `JoinSet` — callers don't need to
77    /// keep it. Cancellation of the scope aborts the task at its next
78    /// await.
79    pub fn spawn<Fut>(&mut self, fut: Fut) -> AbortHandle
80    where
81        Fut: Future<Output = ()> + Send + 'static,
82    {
83        self.joins.spawn(fut)
84    }
85
86    /// Signal cancellation to every child task. Returns immediately —
87    /// callers drain the `JoinSet` separately via `drain_completed`.
88    pub fn cancel(&self) {
89        self.token.cancel();
90    }
91
92    /// True iff the scope has been cancelled.
93    pub fn is_cancelled(&self) -> bool {
94        self.token.is_cancelled()
95    }
96
97    /// Join one task if any has completed. Returns `None` immediately
98    /// when the set is empty or nothing is ready. Intended for the
99    /// main loop's per-tick bookkeeping — not a blocking drain.
100    pub async fn join_next(&mut self) -> Option<Result<(), tokio::task::JoinError>> {
101        self.joins.join_next().await
102    }
103
104    /// True iff no child task is currently running inside this scope.
105    /// The main loop uses this after a `cancel()` to decide when to
106    /// transition from `TurnState::Cancelling` back to `Idle`.
107    pub fn is_empty(&self) -> bool {
108        self.joins.is_empty()
109    }
110
111    /// Drain any already-completed tasks from the JoinSet without
112    /// blocking. `JoinSet::is_empty` only flips to true after finished
113    /// tasks are explicitly harvested via `join_next`; without this,
114    /// `EffectRunner::reap_empty_scopes` would see finished-but-not-
115    /// joined scopes as "still busy" and never reap them. F12.
116    pub fn drain_completed(&mut self) {
117        while self.joins.try_join_next().is_some() {}
118    }
119
120    pub fn len(&self) -> usize {
121        self.joins.len()
122    }
123
124    /// Await every outstanding task to completion, swallowing
125    /// `JoinError`s (they happen on normal `abort()`s after
126    /// cancellation). Use during shutdown.
127    pub async fn drain(&mut self) {
128        while let Some(result) = self.joins.join_next().await {
129            if let Err(e) = result
130                && !e.is_cancelled()
131            {
132                tracing::warn!(
133                    turn = %self.id,
134                    error = %e,
135                    "turn_scope: child task panicked"
136                );
137            }
138        }
139    }
140}
141
142impl Drop for TurnScope {
143    fn drop(&mut self) {
144        // If the caller forgot to cancel before dropping, be safe: a
145        // live child task holding resources after the turn ended is
146        // exactly the kind of leak this architecture exists to
147        // prevent. `JoinSet::drop` already aborts its members, but we
148        // still flip the cancellation token so any child that branches
149        // on it observes the abort intent too.
150        if !self.token.is_cancelled() {
151            self.token.cancel();
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::time::Duration;
160
161    #[tokio::test]
162    async fn fresh_scope_has_no_tasks() {
163        let scope = TurnScope::new(TurnId(1));
164        assert_eq!(scope.len(), 0);
165        assert!(scope.is_empty());
166        assert!(!scope.is_cancelled());
167    }
168
169    #[tokio::test]
170    async fn spawned_task_completes_within_scope() {
171        let mut scope = TurnScope::new(TurnId(1));
172        scope.spawn(async {
173            tokio::time::sleep(Duration::from_millis(5)).await;
174        });
175        assert_eq!(scope.len(), 1);
176        // Wait for it.
177        let result = scope.join_next().await;
178        assert!(result.is_some());
179        assert!(scope.is_empty());
180    }
181
182    #[tokio::test]
183    async fn cancel_signals_child_tasks() {
184        let mut scope = TurnScope::new(TurnId(1));
185        let token = scope.token();
186        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<&'static str>();
187        scope.spawn(async move {
188            tokio::select! {
189                _ = token.cancelled() => {
190                    let _ = tx.send("cancelled");
191                },
192                _ = tokio::time::sleep(Duration::from_secs(30)) => {
193                    let _ = tx.send("timeout");
194                },
195            }
196        });
197
198        // Give the task a moment to register its select.
199        tokio::time::sleep(Duration::from_millis(10)).await;
200        scope.cancel();
201        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
202            .await
203            .expect("cancellation should propagate")
204            .expect("sender alive");
205        assert_eq!(msg, "cancelled");
206        scope.drain().await;
207    }
208
209    #[tokio::test]
210    async fn drop_cancels_token() {
211        let token = {
212            let scope = TurnScope::new(TurnId(2));
213            scope.token()
214        };
215        // Scope dropped — token should be cancelled.
216        assert!(token.is_cancelled());
217    }
218
219    #[tokio::test]
220    async fn drain_runs_to_completion_on_normal_tasks() {
221        let mut scope = TurnScope::new(TurnId(3));
222        for i in 0..5 {
223            scope.spawn(async move {
224                tokio::time::sleep(Duration::from_millis(i)).await;
225            });
226        }
227        assert_eq!(scope.len(), 5);
228        scope.drain().await;
229        assert!(scope.is_empty());
230    }
231
232    #[tokio::test]
233    async fn cancel_then_drain_is_quick() {
234        let mut scope = TurnScope::new(TurnId(4));
235        let token = scope.token();
236        for _ in 0..10 {
237            let t = token.clone();
238            scope.spawn(async move {
239                tokio::select! {
240                    _ = t.cancelled() => {},
241                    _ = tokio::time::sleep(Duration::from_secs(60)) => {},
242                }
243            });
244        }
245        scope.cancel();
246        let start = std::time::Instant::now();
247        scope.drain().await;
248        // All tasks cancel and unwind within 100ms (generous bound —
249        // realistic would be <10ms).
250        assert!(
251            start.elapsed() < Duration::from_millis(100),
252            "cancel+drain took {:?}",
253            start.elapsed()
254        );
255    }
256}