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}