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}