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 let Some(result) = self.joins.try_join_next() {
118            // Mirror `drain`: a child that panicked surfaces here as a
119            // non-cancelled `JoinError`. Without this it was harvested and
120            // dropped silently, so a panicking effect task left no trace (#43).
121            if let Err(e) = result
122                && !e.is_cancelled()
123            {
124                tracing::warn!(
125                    turn = %self.id,
126                    error = %e,
127                    "turn_scope: child task panicked"
128                );
129            }
130        }
131    }
132
133    pub fn len(&self) -> usize {
134        self.joins.len()
135    }
136
137    /// Await every outstanding task to completion, swallowing
138    /// `JoinError`s (they happen on normal `abort()`s after
139    /// cancellation). Use during shutdown.
140    pub async fn drain(&mut self) {
141        while let Some(result) = self.joins.join_next().await {
142            if let Err(e) = result
143                && !e.is_cancelled()
144            {
145                tracing::warn!(
146                    turn = %self.id,
147                    error = %e,
148                    "turn_scope: child task panicked"
149                );
150            }
151        }
152    }
153}
154
155impl Drop for TurnScope {
156    fn drop(&mut self) {
157        // If the caller forgot to cancel before dropping, be safe: a
158        // live child task holding resources after the turn ended is
159        // exactly the kind of leak this architecture exists to
160        // prevent. `JoinSet::drop` already aborts its members, but we
161        // still flip the cancellation token so any child that branches
162        // on it observes the abort intent too.
163        if !self.token.is_cancelled() {
164            self.token.cancel();
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::time::Duration;
173
174    #[tokio::test]
175    async fn fresh_scope_has_no_tasks() {
176        let scope = TurnScope::new(TurnId(1));
177        assert_eq!(scope.len(), 0);
178        assert!(scope.is_empty());
179        assert!(!scope.is_cancelled());
180    }
181
182    #[tokio::test]
183    async fn spawned_task_completes_within_scope() {
184        let mut scope = TurnScope::new(TurnId(1));
185        scope.spawn(async {
186            tokio::time::sleep(Duration::from_millis(5)).await;
187        });
188        assert_eq!(scope.len(), 1);
189        // Wait for it.
190        let result = scope.join_next().await;
191        assert!(result.is_some());
192        assert!(scope.is_empty());
193    }
194
195    #[tokio::test]
196    async fn cancel_signals_child_tasks() {
197        let mut scope = TurnScope::new(TurnId(1));
198        let token = scope.token();
199        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<&'static str>();
200        scope.spawn(async move {
201            tokio::select! {
202                _ = token.cancelled() => {
203                    let _ = tx.send("cancelled");
204                },
205                _ = tokio::time::sleep(Duration::from_secs(30)) => {
206                    let _ = tx.send("timeout");
207                },
208            }
209        });
210
211        // Give the task a moment to register its select.
212        tokio::time::sleep(Duration::from_millis(10)).await;
213        scope.cancel();
214        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
215            .await
216            .expect("cancellation should propagate")
217            .expect("sender alive");
218        assert_eq!(msg, "cancelled");
219        scope.drain().await;
220    }
221
222    #[tokio::test]
223    async fn drop_cancels_token() {
224        let token = {
225            let scope = TurnScope::new(TurnId(2));
226            scope.token()
227        };
228        // Scope dropped — token should be cancelled.
229        assert!(token.is_cancelled());
230    }
231
232    #[tokio::test]
233    async fn drain_runs_to_completion_on_normal_tasks() {
234        let mut scope = TurnScope::new(TurnId(3));
235        for i in 0..5 {
236            scope.spawn(async move {
237                tokio::time::sleep(Duration::from_millis(i)).await;
238            });
239        }
240        assert_eq!(scope.len(), 5);
241        scope.drain().await;
242        assert!(scope.is_empty());
243    }
244
245    #[tokio::test]
246    async fn drain_completed_harvests_a_panicked_task() {
247        // #43: a child that panics must still be harvested (and logged) by
248        // `drain_completed`, leaving the scope empty — not stuck on the
249        // un-joined `JoinError`, which would make the scope look "busy" forever.
250        let mut scope = TurnScope::new(TurnId(7));
251        scope.spawn(async {
252            panic!("boom");
253        });
254        // Give the task a tick to run and panic.
255        tokio::time::sleep(Duration::from_millis(10)).await;
256        scope.drain_completed();
257        assert!(scope.is_empty());
258    }
259
260    #[tokio::test]
261    async fn cancel_then_drain_is_quick() {
262        let mut scope = TurnScope::new(TurnId(4));
263        let token = scope.token();
264        for _ in 0..10 {
265            let t = token.clone();
266            scope.spawn(async move {
267                tokio::select! {
268                    _ = t.cancelled() => {},
269                    _ = tokio::time::sleep(Duration::from_secs(60)) => {},
270                }
271            });
272        }
273        scope.cancel();
274        let start = std::time::Instant::now();
275        scope.drain().await;
276        // All tasks cancel and unwind within 100ms (generous bound —
277        // realistic would be <10ms).
278        assert!(
279            start.elapsed() < Duration::from_millis(100),
280            "cancel+drain took {:?}",
281            start.elapsed()
282        );
283    }
284}