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