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