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;
25use std::sync::Arc;
26use std::sync::atomic::AtomicUsize;
27
28use tokio::task::{AbortHandle, JoinSet};
29use tokio_util::sync::CancellationToken;
30
31use crate::domain::TurnId;
32
33/// One turn's cancellable scope. Construct once per `SubmitPrompt`;
34/// abandon (drop) at the end of the turn.
35#[derive(Debug)]
36pub struct TurnScope {
37    id: TurnId,
38    token: CancellationToken,
39    /// A second signal, parallel to `token`, meaning "background the running
40    /// work, don't kill it" (Ctrl+B). Tools that can detach (execute_command)
41    /// `select!` on it; everyone else ignores it.
42    background: CancellationToken,
43    web_bytes: Arc<AtomicUsize>,
44    joins: JoinSet<()>,
45}
46
47impl TurnScope {
48    pub fn new(id: TurnId) -> Self {
49        Self {
50            id,
51            token: CancellationToken::new(),
52            background: CancellationToken::new(),
53            web_bytes: Arc::new(AtomicUsize::new(0)),
54            joins: JoinSet::new(),
55        }
56    }
57
58    pub fn id(&self) -> TurnId {
59        self.id
60    }
61
62    /// Clone the scope's token. Hand this to child tasks so they can
63    /// participate in cooperative cancellation.
64    pub fn token(&self) -> CancellationToken {
65        self.token.clone()
66    }
67
68    /// Clone the scope's background-request token (Ctrl+B). Tools that can
69    /// detach a running child select on this instead of killing it.
70    pub fn background_token(&self) -> CancellationToken {
71        self.background.clone()
72    }
73
74    /// Shared decoded-web-byte counter for every tool call in this turn.
75    pub fn web_bytes(&self) -> Arc<AtomicUsize> {
76        self.web_bytes.clone()
77    }
78
79    /// Signal "background the running work" to every child task that listens.
80    pub fn background(&self) {
81        self.background.cancel();
82    }
83
84    /// Spawn a child task under this scope. The returned handle is
85    /// retained inside the scope's `JoinSet` — callers don't need to
86    /// keep it. Cancellation of the scope aborts the task at its next
87    /// await.
88    pub fn spawn<Fut>(&mut self, fut: Fut) -> AbortHandle
89    where
90        Fut: Future<Output = ()> + Send + 'static,
91    {
92        self.joins.spawn(fut)
93    }
94
95    /// Signal cancellation to every child task. Returns immediately —
96    /// callers drain the `JoinSet` separately via `drain_completed`.
97    pub fn cancel(&self) {
98        self.token.cancel();
99    }
100
101    /// True iff the scope has been cancelled.
102    pub fn is_cancelled(&self) -> bool {
103        self.token.is_cancelled()
104    }
105
106    /// Join one task if any has completed. Returns `None` immediately
107    /// when the set is empty or nothing is ready. Intended for the
108    /// main loop's per-tick bookkeeping — not a blocking drain.
109    pub async fn join_next(&mut self) -> Option<Result<(), tokio::task::JoinError>> {
110        self.joins.join_next().await
111    }
112
113    /// True iff no child task is currently running inside this scope.
114    /// The main loop uses this after a `cancel()` to decide when to
115    /// transition from `TurnState::Cancelling` back to `Idle`.
116    pub fn is_empty(&self) -> bool {
117        self.joins.is_empty()
118    }
119
120    /// Drain any already-completed tasks from the JoinSet without
121    /// blocking. `JoinSet::is_empty` only flips to true after finished
122    /// tasks are explicitly harvested via `join_next`; without this,
123    /// `EffectRunner::reap_empty_scopes` would see finished-but-not-
124    /// joined scopes as "still busy" and never reap them. F12.
125    pub fn drain_completed(&mut self) {
126        while let Some(result) = self.joins.try_join_next() {
127            // Mirror `drain`: a child that panicked surfaces here as a
128            // non-cancelled `JoinError`. Without this it was harvested and
129            // dropped silently, so a panicking effect task left no trace (#43).
130            if let Err(e) = result
131                && !e.is_cancelled()
132            {
133                tracing::warn!(
134                    turn = %self.id,
135                    error = %e,
136                    "turn_scope: child task panicked"
137                );
138            }
139        }
140    }
141
142    pub fn len(&self) -> usize {
143        self.joins.len()
144    }
145
146    /// Await every outstanding task to completion, swallowing
147    /// `JoinError`s (they happen on normal `abort()`s after
148    /// cancellation). Use during shutdown.
149    pub async fn drain(&mut self) {
150        while let Some(result) = self.joins.join_next().await {
151            if let Err(e) = result
152                && !e.is_cancelled()
153            {
154                tracing::warn!(
155                    turn = %self.id,
156                    error = %e,
157                    "turn_scope: child task panicked"
158                );
159            }
160        }
161    }
162}
163
164impl Drop for TurnScope {
165    fn drop(&mut self) {
166        // If the caller forgot to cancel before dropping, be safe: a
167        // live child task holding resources after the turn ended is
168        // exactly the kind of leak this architecture exists to
169        // prevent. `JoinSet::drop` already aborts its members, but we
170        // still flip the cancellation token so any child that branches
171        // on it observes the abort intent too.
172        if !self.token.is_cancelled() {
173            self.token.cancel();
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::time::Duration;
182
183    #[tokio::test]
184    async fn fresh_scope_has_no_tasks() {
185        let scope = TurnScope::new(TurnId(1));
186        assert_eq!(scope.len(), 0);
187        assert!(scope.is_empty());
188        assert!(!scope.is_cancelled());
189    }
190
191    #[tokio::test]
192    async fn spawned_task_completes_within_scope() {
193        let mut scope = TurnScope::new(TurnId(1));
194        scope.spawn(async {
195            tokio::time::sleep(Duration::from_millis(5)).await;
196        });
197        assert_eq!(scope.len(), 1);
198        // Wait for it.
199        let result = scope.join_next().await;
200        assert!(result.is_some());
201        assert!(scope.is_empty());
202    }
203
204    #[tokio::test]
205    async fn cancel_signals_child_tasks() {
206        let mut scope = TurnScope::new(TurnId(1));
207        let token = scope.token();
208        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<&'static str>();
209        scope.spawn(async move {
210            tokio::select! {
211                _ = token.cancelled() => {
212                    let _ = tx.send("cancelled");
213                },
214                _ = tokio::time::sleep(Duration::from_secs(30)) => {
215                    let _ = tx.send("timeout");
216                },
217            }
218        });
219
220        // Give the task a moment to register its select.
221        tokio::time::sleep(Duration::from_millis(10)).await;
222        scope.cancel();
223        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
224            .await
225            .expect("cancellation should propagate")
226            .expect("sender alive");
227        assert_eq!(msg, "cancelled");
228        scope.drain().await;
229    }
230
231    #[tokio::test]
232    async fn drop_cancels_token() {
233        let token = {
234            let scope = TurnScope::new(TurnId(2));
235            scope.token()
236        };
237        // Scope dropped — token should be cancelled.
238        assert!(token.is_cancelled());
239    }
240
241    #[tokio::test]
242    async fn drain_runs_to_completion_on_normal_tasks() {
243        let mut scope = TurnScope::new(TurnId(3));
244        for i in 0..5 {
245            scope.spawn(async move {
246                tokio::time::sleep(Duration::from_millis(i)).await;
247            });
248        }
249        assert_eq!(scope.len(), 5);
250        scope.drain().await;
251        assert!(scope.is_empty());
252    }
253
254    #[tokio::test]
255    async fn drain_completed_harvests_a_panicked_task() {
256        // #43: a child that panics must still be harvested (and logged) by
257        // `drain_completed`, leaving the scope empty — not stuck on the
258        // un-joined `JoinError`, which would make the scope look "busy" forever.
259        let mut scope = TurnScope::new(TurnId(7));
260        scope.spawn(async {
261            panic!("boom");
262        });
263        // Give the task a tick to run and panic.
264        tokio::time::sleep(Duration::from_millis(10)).await;
265        scope.drain_completed();
266        assert!(scope.is_empty());
267    }
268
269    #[tokio::test]
270    async fn cancel_then_drain_is_quick() {
271        let mut scope = TurnScope::new(TurnId(4));
272        let token = scope.token();
273        for _ in 0..10 {
274            let t = token.clone();
275            scope.spawn(async move {
276                tokio::select! {
277                    _ = t.cancelled() => {},
278                    _ = tokio::time::sleep(Duration::from_secs(60)) => {},
279                }
280            });
281        }
282        scope.cancel();
283        let start = std::time::Instant::now();
284        scope.drain().await;
285        // All tasks cancel and unwind within 100ms (generous bound —
286        // realistic would be <10ms).
287        assert!(
288            start.elapsed() < Duration::from_millis(100),
289            "cancel+drain took {:?}",
290            start.elapsed()
291        );
292    }
293}