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}