auralis_task/scope.rs
1//! Explicit [`TaskScope`] tree with iterative cancellation, parent
2//! back-references, callback-handle lifecycle management, and a context
3//! store for dependency injection.
4
5use std::any::{Any, TypeId};
6use std::cell::{Cell, RefCell};
7use std::collections::{HashMap, VecDeque};
8use std::fmt;
9use std::future::Future;
10use std::rc::{Rc, Weak};
11
12use auralis_signal::{Memo, Signal};
13
14use crate::executor;
15use crate::Priority;
16
17type ScopeId = u64;
18type TaskId = u64;
19
20// ---------------------------------------------------------------------------
21// Scope-id allocator
22// ---------------------------------------------------------------------------
23
24thread_local! {
25 static NEXT_SCOPE_ID: Cell<ScopeId> = const { Cell::new(1) };
26}
27
28fn alloc_scope_id() -> ScopeId {
29 NEXT_SCOPE_ID.with(|c| {
30 let id = c.get();
31 c.set(id + 1);
32 id
33 })
34}
35
36// ---------------------------------------------------------------------------
37// CallbackHandle
38// ---------------------------------------------------------------------------
39
40/// Owns a resource that must be cleaned up when the owning [`TaskScope`]
41/// is dropped.
42///
43/// Currently used for signal subscriptions registered by the `bind_*`
44/// functions. When the [`TaskScope`] drops, every registered
45/// [`CallbackHandle`] is dropped, which calls the stored cleanup closure
46/// to unsubscribe from the signal.
47pub struct CallbackHandle {
48 cleanup: Option<Box<dyn FnOnce() + 'static>>,
49}
50
51impl CallbackHandle {
52 /// Create a handle from a cleanup closure.
53 pub fn new(cleanup: impl FnOnce() + 'static) -> Self {
54 Self {
55 cleanup: Some(Box::new(cleanup)),
56 }
57 }
58
59 /// Create a no-op handle that does nothing on drop.
60 ///
61 /// Useful as a placeholder when a [`CallbackHandle`] is required
62 /// but no cleanup is needed — for example, in framework glue code
63 /// that always calls [`register_callback_handle`](TaskScope::register_callback_handle)
64 /// but whose inner binding may be a no-op.
65 #[must_use]
66 pub fn noop() -> Self {
67 Self { cleanup: None }
68 }
69}
70
71impl Drop for CallbackHandle {
72 fn drop(&mut self) {
73 if let Some(f) = self.cleanup.take() {
74 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
75 }
76 }
77}
78
79// ---------------------------------------------------------------------------
80// Scope registry — maps ScopeId → live TaskScope for executor injection
81// ---------------------------------------------------------------------------
82//
83// # Why Weak references
84//
85// The registry stores `Weak<RefCell<TaskScopeInner>>` rather than
86// `Rc<...>`. This prevents the registry from keeping scopes alive
87// after the application has dropped them — when the last strong
88// reference is gone, the Weak upgrade returns `None` and the executor
89// skips that scope.
90//
91// # Thread safety
92//
93// `SCOPE_REGISTRY` is a `thread_local!` because Auralis is
94// single-threaded by design (Wasm constraint). For multi-task SSR
95// servers, each request uses an isolated [`Executor`] instance created
96// via [`Executor::new_instance`](crate::Executor::new_instance), and
97// the [`ScopeStore`] trait provides pluggable per-task storage.
98
99type ScopeRegistryEntry = (Weak<RefCell<TaskScopeInner>>, Weak<Cell<bool>>);
100
101thread_local! {
102 static SCOPE_REGISTRY: RefCell<HashMap<ScopeId, ScopeRegistryEntry>> =
103 RefCell::new(HashMap::new());
104}
105
106/// Register a scope in the global registry so the executor can look it
107/// up by id and inject it as the current scope when polling tasks.
108fn register_scope(id: ScopeId, inner: &Rc<RefCell<TaskScopeInner>>, suspended: &Rc<Cell<bool>>) {
109 let _ = SCOPE_REGISTRY.try_with(|reg| {
110 if let Ok(mut r) = reg.try_borrow_mut() {
111 r.insert(id, (Rc::downgrade(inner), Rc::downgrade(suspended)));
112 }
113 });
114}
115
116fn unregister_scope(id: ScopeId) {
117 let _ = SCOPE_REGISTRY.try_with(|reg| {
118 if let Ok(mut r) = reg.try_borrow_mut() {
119 r.remove(&id);
120 }
121 });
122}
123
124/// Find a live [`TaskScope`] by its id.
125///
126/// Returns `None` if the scope has been dropped or the id is unknown.
127#[must_use]
128pub fn find_scope(scope_id: ScopeId) -> Option<TaskScope> {
129 SCOPE_REGISTRY
130 .try_with(|reg| {
131 if let Ok(r) = reg.try_borrow() {
132 r.get(&scope_id).and_then(|(inner_weak, suspended_weak)| {
133 let inner = inner_weak.upgrade()?;
134 let suspended = suspended_weak.upgrade()?;
135 let cancelled = inner.borrow().cancelled.clone();
136 Some(TaskScope {
137 inner,
138 cancelled,
139 suspended,
140 })
141 })
142 } else {
143 None
144 }
145 })
146 .ok()
147 .flatten()
148}
149
150/// Return the debug label for the scope with the given id, if any.
151///
152/// Only available with the `debug` feature.
153#[cfg(feature = "debug")]
154#[doc(hidden)]
155#[must_use]
156pub fn scope_debug_label(scope_id: ScopeId) -> Option<String> {
157 find_scope(scope_id).and_then(|s| s.inner.borrow().label.clone())
158}
159
160/// Clear the scope registry.
161#[doc(hidden)]
162pub fn clear_scope_registry() {
163 let _ = SCOPE_REGISTRY.try_with(|reg| {
164 if let Ok(mut r) = reg.try_borrow_mut() {
165 r.clear();
166 }
167 });
168}
169
170// ---------------------------------------------------------------------------
171// Current-scope storage — injectable, defaults to thread-local
172// ---------------------------------------------------------------------------
173
174/// Function signatures for scope store operations.
175///
176/// Using function pointers keeps the store `Send + Sync` even though
177/// `TaskScope` itself is `!Send` — Rust function pointer types are
178/// always `Send + Sync` regardless of parameter/return types.
179type ScopeSetFn = fn(Option<TaskScope>);
180type ScopeGetFn = fn() -> Option<TaskScope>;
181
182/// A pluggable backend for per-task (or per-thread) scope storage.
183///
184/// The default implementation uses a thread-local cell, which is
185/// sufficient for single-threaded Wasm environments. For multi-task
186/// SSR runtimes (e.g. tokio) the host application should inject a
187/// task-local implementation via [`set_scope_store`].
188#[derive(Debug)]
189pub struct ScopeStore {
190 /// Store a scope (or `None` to clear).
191 pub set_fn: ScopeSetFn,
192 /// Retrieve the current scope.
193 pub get_fn: ScopeGetFn,
194}
195
196use std::sync::OnceLock;
197static SCOPE_STORE: OnceLock<ScopeStore> = OnceLock::new();
198
199fn ensure_default_store() -> &'static ScopeStore {
200 SCOPE_STORE.get_or_init(|| ScopeStore {
201 set_fn: thread_local_set,
202 get_fn: thread_local_get,
203 })
204}
205
206/// Install a custom scope store.
207///
208/// Must be called before any scope operations (i.e. before any
209/// [`TaskScope::new`], [`current_scope`], etc.). On Wasm or in tests
210/// the default thread-local store is sufficient.
211///
212/// Returns `Ok(())` on success, or `Err(store)` if a store was already
213/// installed (either by a previous call to this function or via
214/// [`init_scope_store_tokio`]).
215///
216/// # Errors
217///
218/// Returns the provided `store` back inside `Err` if the global store
219/// has already been initialised. This happens when [`set_scope_store`]
220/// or [`init_scope_store_tokio`] was called previously, or when any
221/// scope operation (e.g. [`TaskScope::new`]) has already triggered the
222/// default thread-local store installation.
223///
224/// # Example (tokio SSR)
225///
226/// ```rust,ignore
227/// use auralis_task::ScopeStore;
228///
229/// auralis_task::set_scope_store(ScopeStore {
230/// set_fn: my_tokio_task_local_set,
231/// get_fn: my_tokio_task_local_get,
232/// }).expect("scope store already initialised");
233/// ```
234pub fn set_scope_store(store: ScopeStore) -> Result<(), ScopeStore> {
235 SCOPE_STORE.set(store)
236}
237
238// The `set_scope_store` API allows injecting a custom scope store.
239// For SSR in multi-threaded tokio runtimes, users should implement a
240// `ScopeStore` backed by `tokio::task::LocalKey` (available when the
241// `ssr-tokio` feature is enabled) or a similar per-task mechanism.
242//
243// Example with tokio (when `ssr-tokio` is enabled):
244//
245// ```rust,ignore
246// use auralis_task::{ScopeStore, set_scope_store};
247//
248// tokio::task::LocalKey! {
249// static TK_SCOPE: std::cell::RefCell<Option<auralis_task::TaskScope>> =
250// const { std::cell::RefCell::new(None) };
251// }
252//
253// set_scope_store(ScopeStore {
254// set_fn: |s| TK_SCOPE.with(|c| *c.borrow_mut() = s),
255// get_fn: || TK_SCOPE.with(|c| c.borrow().clone()),
256// });
257// ```
258//
259// For single-threaded tokio use (LocalSet / spawn_local), the default
260// thread-local store works correctly without any configuration.
261
262// ---- default thread-local implementation -------------------------------
263
264thread_local! {
265 static CURRENT_SCOPE: RefCell<Option<TaskScope>> = const { RefCell::new(None) };
266}
267
268fn thread_local_set(scope: Option<TaskScope>) {
269 CURRENT_SCOPE.with(|cell| {
270 cell.replace(scope);
271 });
272}
273
274fn thread_local_get() -> Option<TaskScope> {
275 CURRENT_SCOPE.with(|cell| cell.borrow().clone())
276}
277
278/// Directly set the current scope without save/restore.
279///
280/// Used by the executor to inject the owning scope before polling a
281/// task. The caller must restore the previous scope after the poll.
282pub(crate) fn set_scope_direct(scope: Option<TaskScope>) {
283 let store = ensure_default_store();
284 (store.set_fn)(scope);
285}
286
287/// Directly get the current scope.
288pub(crate) fn get_scope_direct() -> Option<TaskScope> {
289 let store = ensure_default_store();
290 (store.get_fn)()
291}
292
293// ---- ssr-tokio integration ----------------------------------------------
294
295/// Initialise the scope store for tokio-based SSR runtimes.
296///
297/// Uses `tokio::task::LocalKey` to store the current [`TaskScope`] per
298/// tokio task, enabling true multi-request isolation. Call this once
299/// at process startup, **before** any scope operations.
300///
301/// Only available with the **`ssr-tokio`** feature (non-wasm).
302///
303/// # Panics
304///
305/// Panics if any scope operation has already occurred (a default
306/// thread-local store would have been installed by then). Call this
307/// at the very beginning of `main()` or the runtime bootstrap.
308///
309/// # Example
310///
311/// ```rust,ignore
312/// auralis_task::init_scope_store_tokio();
313/// ```
314#[cfg(feature = "ssr-tokio")]
315pub fn init_scope_store_tokio() {
316 tokio::task_local! {
317 static TK_SCOPE: std::cell::RefCell<Option<TaskScope>>;
318 }
319
320 // Initialise the key.
321 let _ = TK_SCOPE.try_with(|cell| {
322 cell.replace(None);
323 });
324
325 set_scope_store(ScopeStore {
326 set_fn: |s| {
327 let _ = TK_SCOPE.try_with(|cell| {
328 cell.replace(s);
329 });
330 },
331 get_fn: || {
332 TK_SCOPE
333 .try_with(|cell| cell.borrow().clone())
334 .ok()
335 .flatten()
336 },
337 })
338 .expect("init_scope_store_tokio must be called BEFORE any scope operations");
339}
340
341// ---- public API --------------------------------------------------------
342
343/// Set the current [`TaskScope`] for the duration of `f`.
344///
345/// Set `scope` as the current scope for the duration of `f`,
346/// restoring the previous scope afterward.
347///
348/// Used by framework glue code so that bind functions can discover the
349/// owning scope via [`current_scope`].
350pub fn with_current_scope<R>(scope: &TaskScope, f: impl FnOnce() -> R) -> R {
351 let store = ensure_default_store();
352 let prev = (store.get_fn)();
353 (store.set_fn)(Some(scope.clone_inner()));
354 let result = f();
355 (store.set_fn)(prev);
356 result
357}
358
359/// Get the currently active [`TaskScope`], if any.
360#[must_use]
361pub fn current_scope() -> Option<TaskScope> {
362 let store = ensure_default_store();
363 (store.get_fn)()
364}
365
366// ---------------------------------------------------------------------------
367// TaskScopeInner
368// ---------------------------------------------------------------------------
369
370struct TaskScopeInner {
371 id: ScopeId,
372 task_ids: Vec<TaskId>,
373 children: Vec<TaskScope>,
374 /// Weak back-reference to parent (set for child scopes).
375 parent: Option<Weak<RefCell<TaskScopeInner>>>,
376 /// Typed context store for dependency injection.
377 context: RefCell<HashMap<TypeId, Rc<dyn Any>>>,
378 /// Callback handles registered by bind_* functions.
379 callbacks: RefCell<Vec<CallbackHandle>>,
380 /// Whether this scope has been cancelled. Stored as `Rc<Cell<bool>>`
381 /// so it can be read/set without borrowing the `RefCell`, avoiding
382 /// re-entrant borrow failures during drop. `TaskScope` holds a clone
383 /// of the same `Rc` for direct access.
384 cancelled: Rc<Cell<bool>>,
385 /// Optional label for `dump_reactive_graph` output.
386 label: Option<String>,
387 /// The executor that owns tasks spawned in this scope.
388 /// Stored as `Rc` (strong reference) so the executor lives
389 /// at least as long as the scope — essential for safe
390 /// cancellation during drop.
391 executor: executor::ExecutorRef,
392}
393
394// ---------------------------------------------------------------------------
395// JoinHandle — per-task cancellation handle
396// ---------------------------------------------------------------------------
397
398/// A handle to a spawned task, allowing individual cancellation.
399///
400/// Created by [`TaskScope::spawn`] and [`TaskScope::spawn_with_priority`].
401/// Dropping the handle does **not** cancel the task — call [`cancel`](JoinHandle::cancel)
402/// explicitly, or drop the owning [`TaskScope`] to cancel all tasks at once.
403pub struct JoinHandle {
404 task_id: Option<TaskId>,
405 executor: executor::ExecutorRef,
406}
407
408impl JoinHandle {
409 /// Cancel this specific task.
410 ///
411 /// Cancellation drops the task's [`Future`] on the next executor
412 /// flush — it is cooperative (`.await`-bound), same as all async
413 /// cancellation in Rust.
414 ///
415 /// No-op if the task has already completed or was spawned into an
416 /// already-cancelled scope.
417 pub fn cancel(&self) {
418 if let Some(tid) = self.task_id {
419 executor::cancel_task(&self.executor, tid);
420 }
421 }
422
423 /// Return `true` if the task has completed (normally or via cancellation).
424 ///
425 /// Returns `true` for handles created by spawning into an already-cancelled
426 /// scope (they never had a real task).
427 #[must_use]
428 pub fn is_finished(&self) -> bool {
429 match self.task_id {
430 Some(tid) => executor::is_task_finished(&self.executor, tid),
431 None => true,
432 }
433 }
434
435 /// Return the id of the wrapped task, or `None` if the handle was
436 /// created by spawning into an already-cancelled scope.
437 #[must_use]
438 pub fn task_id(&self) -> Option<TaskId> {
439 self.task_id
440 }
441}
442
443impl fmt::Debug for JoinHandle {
444 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
445 f.debug_struct("JoinHandle")
446 .field("task_id", &self.task_id)
447 .finish_non_exhaustive()
448 }
449}
450
451// ---------------------------------------------------------------------------
452// TaskScope
453// ---------------------------------------------------------------------------
454
455/// A node in the scope tree that owns spawned tasks and carries a typed
456/// context for dependency injection.
457///
458/// # Drop guarantee
459///
460/// When a [`TaskScope`] is dropped, all descendant scopes and their
461/// tasks are cancelled **iteratively** using a work queue — recursion
462/// is never used, so deeply nested UI trees (200+ levels) never
463/// overflow the stack.
464///
465/// # Cancellation is cooperative
466///
467/// Cancellation drops the task's [`Future`] and removes it from the
468/// executor. Like all async Rust, this only takes effect at the next
469/// `.await` point — a task stuck in a synchronous compute loop cannot
470/// be interrupted mid-execution. This is the same trade-off made by
471/// `tokio::task::JoinHandle::abort`. For long synchronous work,
472/// insert [`yield_now`](crate::yield_now) at checkpoints.
473///
474/// # Context
475///
476/// Use [`provide`](TaskScope::provide) / [`consume`](TaskScope::consume)
477/// for lightweight dependency injection that walks up the scope tree.
478///
479/// # Callback lifecycle
480///
481/// [`CallbackHandle`]s registered via
482/// [`register_callback_handle`](Self::register_callback_handle) are
483/// dropped **before** spawned tasks are cancelled, ensuring that
484/// signal subscriptions are removed before any task cleanup.
485#[must_use]
486pub struct TaskScope {
487 inner: Rc<RefCell<TaskScopeInner>>,
488 /// Whether this scope has been cancelled (dropped). Stored outside
489 /// the `RefCell` so that [`is_cancelled`](Self::is_cancelled) can be
490 /// checked and set without borrowing — avoids re-entrant borrow
491 /// panics and ensures the cancelled flag is always set even when
492 /// the inner `RefCell` is already borrowed during drop.
493 cancelled: Rc<Cell<bool>>,
494 /// Whether this scope is suspended. Stored outside the `RefCell`
495 /// for the same reason as `cancelled`.
496 suspended: Rc<Cell<bool>>,
497}
498
499impl TaskScope {
500 /// Create a new root scope on the global thread-local executor.
501 ///
502 /// For explicit executor ownership use [`TaskScope::with_executor`].
503 pub fn new() -> Self {
504 Self::with_executor(&executor::current_executor_instance())
505 }
506
507 /// Create a new root scope on the given executor.
508 ///
509 /// All tasks spawned in this scope (and its descendants) run on
510 /// `ex`. The scope holds a strong reference, keeping the executor
511 /// alive at least as long as the scope.
512 pub fn with_executor(ex: &executor::ExecutorRef) -> Self {
513 let cancelled = Rc::new(Cell::new(false));
514 let inner = Rc::new(RefCell::new(TaskScopeInner {
515 id: alloc_scope_id(),
516 task_ids: Vec::new(),
517 children: Vec::new(),
518 parent: None,
519 context: RefCell::new(HashMap::new()),
520 callbacks: RefCell::new(Vec::new()),
521 cancelled: Rc::clone(&cancelled),
522 label: None,
523 executor: Rc::clone(ex),
524 }));
525 let id = inner.borrow().id;
526 let suspended = Rc::new(Cell::new(false));
527 register_scope(id, &inner, &suspended);
528 Self {
529 inner,
530 cancelled,
531 suspended,
532 }
533 }
534
535 /// Create a child scope that inherits the parent's executor.
536 ///
537 /// The child is stored in the parent's children list. This means
538 /// dropping all external clones of the child does **not** immediately
539 /// cancel it — the parent's strong reference keeps it alive. The
540 /// child is fully cancelled only when the parent itself is dropped
541 /// (or when [`TaskScope::drop`] runs on the last reference).
542 ///
543 /// To explicitly cancel a child while the parent is still alive,
544 /// call [`suspend`](Self::suspend) on the child, or use a
545 /// [`JoinHandle`] to cancel individual tasks.
546 pub fn new_child(parent: &Self) -> Self {
547 let ex = parent.inner.borrow().executor.clone();
548 let cancelled = Rc::new(Cell::new(false));
549 let inner = Rc::new(RefCell::new(TaskScopeInner {
550 id: alloc_scope_id(),
551 task_ids: Vec::new(),
552 children: Vec::new(),
553 parent: Some(Rc::downgrade(&parent.inner)),
554 context: RefCell::new(HashMap::new()),
555 callbacks: RefCell::new(Vec::new()),
556 cancelled: Rc::clone(&cancelled),
557 label: None,
558 executor: ex,
559 }));
560 let id = inner.borrow().id;
561 let suspended = Rc::new(Cell::new(false));
562 register_scope(id, &inner, &suspended);
563 let child = Self {
564 inner,
565 cancelled,
566 suspended,
567 };
568 parent.inner.borrow_mut().children.push(child.clone_inner());
569 child
570 }
571
572 /// Spawn a future in this scope at low priority.
573 ///
574 /// Returns a [`JoinHandle`] that can cancel this individual task.
575 /// Drop the handle to detach (the task keeps running until the
576 /// scope is dropped).
577 pub fn spawn(&self, future: impl Future<Output = ()> + 'static) -> JoinHandle {
578 self.spawn_with_priority(Priority::Low, future)
579 }
580
581 /// Spawn a future in this scope at the given priority.
582 ///
583 /// The current scope is set to `self` during the spawn so that any
584 /// synchronous work inside the future constructor (e.g. `bind_text`)
585 /// can discover the owning scope via [`current_scope`].
586 ///
587 /// Returns a [`JoinHandle`] that can cancel this individual task.
588 pub fn spawn_with_priority(
589 &self,
590 priority: Priority,
591 future: impl Future<Output = ()> + 'static,
592 ) -> JoinHandle {
593 // Extract fields before spawning so the Ref borrow is released.
594 // If the scheduler fires synchronously (e.g. TestScheduleFlush),
595 // the spawned task's future is polled immediately, and a nested
596 // spawn on the current scope would panic if `inner` were still
597 // borrowed.
598 let (cancelled, ex, scope_id) = {
599 let inner = self.inner.borrow();
600 (inner.cancelled.get(), Rc::clone(&inner.executor), inner.id)
601 };
602 if cancelled {
603 return JoinHandle {
604 task_id: None,
605 executor: ex,
606 };
607 }
608 let task_id = executor::with_executor(&ex, || {
609 with_current_scope(self, || {
610 executor::spawn_scoped_on(&ex, priority, scope_id, future)
611 })
612 });
613 self.inner.borrow_mut().task_ids.push(task_id);
614 JoinHandle {
615 task_id: Some(task_id),
616 executor: ex,
617 }
618 }
619
620 /// Spawn a task that calls `f` with the new value whenever `sig` changes.
621 ///
622 /// This is a convenience wrapper around the common pattern:
623 ///
624 /// ```ignore
625 /// scope.spawn({
626 /// let s = sig.clone();
627 /// async move { loop { s.changed().await; f(&s.read()); } }
628 /// });
629 /// ```
630 ///
631 /// Returns a [`JoinHandle`] for individual cancellation.
632 pub fn watch<T: Clone + 'static>(
633 &self,
634 sig: &Signal<T>,
635 f: impl FnMut(&T) + 'static,
636 ) -> JoinHandle {
637 let s = sig.clone();
638 let mut f = f;
639 self.spawn(async move {
640 loop {
641 s.changed().await;
642 f(&s.read());
643 }
644 })
645 }
646
647 /// Spawn a task that re-runs `effect` whenever any [`Signal`] read
648 /// inside it changes — using a [`Memo`](auralis_signal::Memo) internally
649 /// to auto-track dependencies.
650 ///
651 /// The effect is run once immediately to discover its dependencies.
652 /// Subsequent runs happen on the executor when a dependency changes.
653 ///
654 /// Returns a [`JoinHandle`] for individual cancellation.
655 pub fn watch_effect(&self, effect: impl Fn() + 'static) -> JoinHandle {
656 let memo = Memo::new(effect);
657 self.spawn(async move {
658 loop {
659 memo.changed().await;
660 #[allow(clippy::let_unit_value, clippy::ignored_unit_patterns)]
661 let _ = memo.read();
662 }
663 })
664 }
665
666 // -- callback lifecycle ------------------------------------------------
667
668 /// Register a [`CallbackHandle`] that will be dropped when this scope
669 /// is dropped (or when `clear_callbacks` is called).
670 ///
671 /// Used by `bind_*` functions to ensure signal subscriptions are
672 /// cleaned up when the owning component is destroyed.
673 pub fn register_callback_handle(&self, handle: CallbackHandle) {
674 let inner = self.inner.borrow();
675 if inner.cancelled.get() {
676 return;
677 }
678 inner.callbacks.borrow_mut().push(handle);
679 }
680
681 /// Register a cleanup function that runs when this scope is dropped.
682 ///
683 /// Equivalent to `register_callback_handle(CallbackHandle::new(f))`.
684 ///
685 /// Cleanup functions run before spawned tasks are cancelled, so they
686 /// can safely interact with signals and other resources.
687 ///
688 /// If the scope is already cancelled, `f` is dropped immediately.
689 pub fn on_cleanup(&self, f: impl FnOnce() + 'static) {
690 self.register_callback_handle(CallbackHandle::new(f));
691 }
692
693 // -- context -----------------------------------------------------------
694
695 /// Store a value of type `T` in this scope.
696 ///
697 /// The value is wrapped in [`Rc`] so it can be shared. A subsequent
698 /// call to [`consume`](TaskScope::consume) on this scope (or any
699 /// descendant) will discover it by walking up the parent chain.
700 pub fn provide<T: 'static>(&self, value: T) {
701 self.inner
702 .borrow()
703 .context
704 .borrow_mut()
705 .insert(TypeId::of::<T>(), Rc::new(value));
706 }
707
708 /// Look up a value of type `T` by walking up the scope tree.
709 ///
710 /// Returns `None` if no ancestor (including `self`) has provided a
711 /// value of this type.
712 #[must_use]
713 pub fn consume<T: 'static>(&self) -> Option<Rc<T>> {
714 let mut current = Some(Rc::clone(&self.inner));
715
716 while let Some(inner) = current {
717 // Check local context.
718 {
719 let inner_ref = inner.borrow();
720 let ctx = inner_ref.context.borrow();
721 if let Some(val) = ctx.get(&TypeId::of::<T>()) {
722 if let Ok(downcast) = val.clone().downcast::<T>() {
723 return Some(downcast);
724 }
725 }
726 }
727
728 // Walk up to parent.
729 let parent = {
730 let inner_ref = inner.borrow();
731 inner_ref.parent.as_ref().and_then(Weak::upgrade)
732 };
733 current = parent;
734 }
735
736 None
737 }
738
739 /// Like [`consume`](TaskScope::consume) but panics if the value is
740 /// not found.
741 ///
742 /// # Panics
743 ///
744 /// Panics if no ancestor scope has provided a value of type `T`.
745 #[must_use]
746 #[track_caller]
747 pub fn expect_context<T: 'static>(&self) -> Rc<T> {
748 self.consume::<T>()
749 .unwrap_or_else(|| panic!("context not found: {}", std::any::type_name::<T>()))
750 }
751
752 /// Return `true` if this scope has been cancelled (dropped).
753 ///
754 /// A cancelled scope silently ignores [`spawn`](TaskScope::spawn) calls.
755 #[must_use]
756 pub fn is_cancelled(&self) -> bool {
757 self.cancelled.get()
758 }
759
760 // -- debugging ----------------------------------------------------------
761
762 /// Set a human-readable label for this scope.
763 ///
764 /// Labels appear in `dump_reactive_graph()` output and are useful
765 /// for debugging.
766 pub fn set_label(&self, label: impl Into<String>) {
767 self.inner.borrow_mut().label = Some(label.into());
768 }
769
770 /// Return the label set by [`set_label`](Self::set_label), if any.
771 #[must_use]
772 pub fn label(&self) -> Option<String> {
773 self.inner.borrow().label.clone()
774 }
775
776 /// Set a label for this scope, shown in [`dump_task_tree`] output.
777 ///
778 /// Only available with the `debug` feature.
779 #[cfg(feature = "debug")]
780 #[doc(hidden)]
781 #[deprecated(note = "use `set_label` instead")]
782 pub fn set_debug_label(&self, label: impl Into<String>) {
783 self.set_label(label);
784 }
785
786 // -- testing -----------------------------------------------------------
787
788 /// Return the number of spawned tasks in this scope (test-only).
789 #[cfg(test)]
790 #[must_use]
791 pub fn task_count(&self) -> usize {
792 self.inner.borrow().task_ids.len()
793 }
794
795 /// Return the number of child scopes (test-only).
796 #[cfg(test)]
797 #[must_use]
798 pub fn child_count(&self) -> usize {
799 self.inner.borrow().children.len()
800 }
801
802 // -- internals ---------------------------------------------------------
803
804 fn clone_inner(&self) -> Self {
805 Self {
806 inner: Rc::clone(&self.inner),
807 cancelled: Rc::clone(&self.cancelled),
808 suspended: Rc::clone(&self.suspended),
809 }
810 }
811
812 /// Run `f` with `self` set as the current scope for the thread.
813 ///
814 /// Used by framework glue code so bind functions can discover the
815 /// owning scope via [`current_scope`].
816 pub fn enter<R>(&self, f: impl FnOnce() -> R) -> R {
817 with_current_scope(self, f)
818 }
819
820 /// Suspend all tasks owned by this scope and its descendants.
821 ///
822 /// Suspended tasks are skipped during executor polling. Signal
823 /// subscriptions remain registered but their callbacks are not
824 /// invoked while the scope is suspended. Use [`resume`](Self::resume)
825 /// to restart execution.
826 ///
827 /// Used by `if_async_cached` and `match_async_cached` to pause
828 /// hidden branches.
829 pub fn suspend(&self) {
830 if self.suspended.get() {
831 return;
832 }
833 self.suspended.set(true);
834 // Cascading: suspend all descendants.
835 let children: Vec<TaskScope> = {
836 self.inner
837 .borrow()
838 .children
839 .iter()
840 .map(TaskScope::clone_inner)
841 .collect()
842 };
843 for child in &children {
844 child.suspend();
845 }
846 }
847
848 /// Resume all tasks owned by this scope and its descendants.
849 ///
850 /// This reverses the effect of [`suspend`](Self::suspend). Tasks
851 /// become eligible for polling again on the next executor flush.
852 pub fn resume(&self) {
853 if !self.suspended.get() {
854 return;
855 }
856 self.suspended.set(false);
857
858 let (task_ids, children) = {
859 let inner = self.inner.borrow();
860 let tids = inner.task_ids.clone();
861 let children: Vec<TaskScope> =
862 inner.children.iter().map(TaskScope::clone_inner).collect();
863 (tids, children)
864 };
865
866 // Enqueue all tasks belonging to this scope.
867 let ex = Rc::clone(&self.inner.borrow().executor);
868 executor::enqueue_scope_tasks_on(&ex, &task_ids);
869
870 // Resume children (cascading).
871 for child in &children {
872 child.resume();
873 }
874 }
875
876 /// Return `true` if this scope is currently suspended.
877 #[must_use]
878 pub fn is_suspended(&self) -> bool {
879 self.suspended.get()
880 }
881}
882
883impl Default for TaskScope {
884 fn default() -> Self {
885 Self::new()
886 }
887}
888
889impl Clone for TaskScope {
890 fn clone(&self) -> Self {
891 self.clone_inner()
892 }
893}
894
895// Iterative cancellation: descendants are collected BFS, then
896// cancelled leaf→root, avoiding recursive drop that would overflow
897// the stack on deeply-nested trees (200+ levels).
898//
899// Callback handles are dropped BEFORE tasks, ensuring signal
900// subscriptions are removed before any task is cancelled.
901impl Drop for TaskScope {
902 fn drop(&mut self) {
903 // Only cancel when this is the last reference to the inner.
904 // Temporary clones (from find_scope during executor flush,
905 // from with_current_scope during spawn) share the same inner
906 // and must not cancel the scope when they go out of scope.
907 if Rc::strong_count(&self.inner) > 1 {
908 return;
909 }
910
911 // Always set cancelled first — this Cell is outside the RefCell
912 // and always writable, so the scope is marked cancelled even if
913 // we can't do full cleanup below.
914 self.cancelled.set(true);
915
916 let Ok(mut inner) = self.inner.try_borrow_mut() else {
917 // Already borrowed — re-entrant drop (e.g. a callback or
918 // spawned task dropped the last clone during executor flush).
919 // Cancelled flag is set, so future spawns are rejected and
920 // the executor will clean up tasks on the next flush.
921 eprintln!(
922 "[auralis-task] WARNING: TaskScope::drop cannot borrow inner \
923 (already borrowed). Tasks and callbacks in this scope will \
924 be cleaned up on the next executor flush. Avoid dropping \
925 the last TaskScope clone inside a callback."
926 );
927 return;
928 };
929
930 // ---- drop callback handles first ---------------------------------
931 inner.callbacks.borrow_mut().clear();
932
933 // ---- collect descendants BFS ------------------------------------
934 let mut descendants: Vec<Rc<RefCell<TaskScopeInner>>> = Vec::new();
935 {
936 let mut queue: VecDeque<Rc<RefCell<TaskScopeInner>>> = VecDeque::new();
937 for child in &inner.children {
938 queue.push_back(Rc::clone(&child.inner));
939 }
940
941 while let Some(scope_rc) = queue.pop_front() {
942 let scope = scope_rc.borrow();
943 for child in &scope.children {
944 queue.push_back(Rc::clone(&child.inner));
945 }
946 descendants.push(Rc::clone(&scope_rc));
947 }
948 }
949
950 // ---- cancel leaves → root ---------------------------------------
951 for scope_rc in descendants.iter().rev() {
952 let mut scope = scope_rc.borrow_mut();
953 if scope.cancelled.get() {
954 continue;
955 }
956 scope.cancelled.set(true);
957
958 // Drop callbacks before tasks.
959 scope.callbacks.borrow_mut().clear();
960
961 if !scope.task_ids.is_empty() {
962 let ex = Rc::clone(&scope.executor);
963 let task_ids = std::mem::take(&mut scope.task_ids);
964 let dropped_futures = executor::cancel_scope_tasks_on(&ex, &task_ids);
965 drop(dropped_futures);
966 }
967 scope.context.borrow_mut().clear();
968 unregister_scope(scope.id);
969 }
970
971 // ---- cancel own tasks -------------------------------------------
972 if !inner.task_ids.is_empty() {
973 let ex = Rc::clone(&inner.executor);
974 let task_ids = std::mem::take(&mut inner.task_ids);
975 let dropped_futures = executor::cancel_scope_tasks_on(&ex, &task_ids);
976 drop(dropped_futures);
977 }
978
979 inner.context.borrow_mut().clear();
980 inner.children.clear();
981
982 // Remove from the global registry so stale lookups return None.
983 unregister_scope(inner.id);
984 }
985}
986
987// ---------------------------------------------------------------------------
988// Convenience macros for context injection / retrieval
989// ---------------------------------------------------------------------------
990
991/// Shorthand for `scope.provide(value)`.
992///
993/// ```rust,ignore
994/// provide_context!(scope, 42i32);
995/// ```
996#[macro_export]
997macro_rules! provide_context {
998 ($scope:expr, $value:expr) => {
999 $scope.provide($value)
1000 };
1001}
1002
1003/// Shorthand for `scope.consume::<T>()`.
1004///
1005/// ```rust,ignore
1006/// let theme: Option<Rc<Theme>> = consume_context!(scope, Theme);
1007/// ```
1008#[macro_export]
1009macro_rules! consume_context {
1010 ($scope:expr, $ty:ty) => {
1011 $scope.consume::<$ty>()
1012 };
1013}
1014
1015// ---------------------------------------------------------------------------
1016// Structured scope tree (debug feature)
1017// ---------------------------------------------------------------------------
1018
1019/// A node in the scope tree, serializable for `DevTools`.
1020#[cfg(feature = "debug")]
1021#[derive(Debug, Clone, serde::Serialize)]
1022pub struct ScopeTreeNode {
1023 /// Unique scope id.
1024 pub id: ScopeId,
1025 /// Label set via `set_label()`.
1026 pub label: Option<String>,
1027 /// Spawned tasks in this scope.
1028 pub tasks: Vec<TaskNode>,
1029 /// Child scopes (recursive).
1030 pub children: Vec<ScopeTreeNode>,
1031}
1032
1033/// A task entry within a scope.
1034#[cfg(feature = "debug")]
1035#[derive(Debug, Clone, serde::Serialize)]
1036pub struct TaskNode {
1037 /// Executor-assigned task id.
1038 pub id: TaskId,
1039 /// `"H"` or `"L"`.
1040 pub priority: &'static str,
1041 /// Whether the task is currently enqueued for polling.
1042 pub queued: bool,
1043 /// Total number of times this task has been polled.
1044 pub total_poll_count: u64,
1045 /// Microseconds spent in the most recent poll.
1046 pub last_poll_duration_us: u64,
1047}
1048
1049/// Recursively assemble a scope sub-tree.
1050#[cfg(feature = "debug")]
1051fn attach_children(
1052 id: u64,
1053 scope_map: &mut std::collections::HashMap<u64, ScopeTreeNode>,
1054 child_map: &std::collections::HashMap<u64, Vec<u64>>,
1055) -> ScopeTreeNode {
1056 let mut node = scope_map.remove(&id).unwrap_or(ScopeTreeNode {
1057 id,
1058 label: None,
1059 tasks: Vec::new(),
1060 children: Vec::new(),
1061 });
1062 if let Some(child_ids) = child_map.get(&id) {
1063 let mut child_ids = child_ids.clone();
1064 child_ids.sort_unstable();
1065 for cid in child_ids {
1066 node.children
1067 .push(attach_children(cid, scope_map, child_map));
1068 }
1069 }
1070 node
1071}
1072
1073/// Build the scope tree from the global scope registry.
1074///
1075/// Root scopes (those with no live parent) form the top-level list.
1076/// Tasks are annotated with their enqueued status.
1077#[cfg(feature = "debug")]
1078#[must_use]
1079pub fn scope_tree() -> Vec<ScopeTreeNode> {
1080 use crate::executor;
1081
1082 let task_snap = executor::debug_task_snapshot();
1083 let queued: std::collections::HashSet<u64> =
1084 executor::debug_queued_task_ids().into_iter().collect();
1085 let timing = executor::debug_task_timing();
1086
1087 // Group tasks by scope_id.
1088 let mut tasks_by_scope: std::collections::HashMap<u64, Vec<TaskNode>> =
1089 std::collections::HashMap::new();
1090 for (tid, pri, sid) in &task_snap {
1091 let (poll_count, last_us) = timing.get(tid).copied().unwrap_or((0, 0));
1092 tasks_by_scope.entry(*sid).or_default().push(TaskNode {
1093 id: *tid,
1094 priority: match pri {
1095 Priority::High => "H",
1096 Priority::Low => "L",
1097 },
1098 queued: queued.contains(tid),
1099 total_poll_count: poll_count,
1100 last_poll_duration_us: last_us,
1101 });
1102 }
1103
1104 // Collect live scopes.
1105 let mut scope_map: std::collections::HashMap<u64, ScopeTreeNode> =
1106 std::collections::HashMap::new();
1107
1108 let _ = SCOPE_REGISTRY.try_with(|reg| {
1109 if let Ok(r) = reg.try_borrow() {
1110 for (&id, (inner_weak, _suspended_weak)) in r.iter() {
1111 let Some(inner) = inner_weak.upgrade() else {
1112 continue;
1113 };
1114 let b = inner.borrow();
1115 scope_map.insert(
1116 id,
1117 ScopeTreeNode {
1118 id,
1119 label: b.label.clone(),
1120 tasks: tasks_by_scope.remove(&id).unwrap_or_default(),
1121 children: Vec::new(), // filled below
1122 },
1123 );
1124 }
1125 }
1126 });
1127
1128 // Build parent-child links as id→[child_id] maps.
1129 let mut roots: Vec<u64> = Vec::new();
1130 let mut child_map: std::collections::HashMap<u64, Vec<u64>> = std::collections::HashMap::new();
1131
1132 let _ = SCOPE_REGISTRY.try_with(|reg| {
1133 if let Ok(r) = reg.try_borrow() {
1134 for (&id, (inner_weak, _)) in r.iter() {
1135 let Some(inner) = inner_weak.upgrade() else {
1136 continue;
1137 };
1138 let b = inner.borrow();
1139 let has_live_parent = b.parent.as_ref().and_then(Weak::upgrade).is_some();
1140 if !has_live_parent {
1141 roots.push(id);
1142 } else if let Some(p) = b.parent.as_ref().and_then(Weak::upgrade) {
1143 child_map.entry(p.borrow().id).or_default().push(id);
1144 }
1145 }
1146 }
1147 });
1148
1149 // Sort tasks within each scope by id for determinism.
1150 for node in scope_map.values_mut() {
1151 node.tasks.sort_by_key(|t| t.id);
1152 }
1153
1154 let mut tree = Vec::new();
1155 roots.sort_unstable();
1156 for rid in roots {
1157 tree.push(attach_children(rid, &mut scope_map, &child_map));
1158 }
1159 // Any remaining scopes not reachable from roots (shouldn't normally
1160 // happen, but be defensive).
1161 let remaining: Vec<u64> = {
1162 let mut ids: Vec<u64> = scope_map.keys().copied().collect();
1163 ids.sort_unstable();
1164 ids
1165 };
1166 for id in remaining {
1167 tree.push(attach_children(id, &mut scope_map, &child_map));
1168 }
1169
1170 tree
1171}
1172
1173// ---------------------------------------------------------------------------
1174// Tests
1175// ---------------------------------------------------------------------------
1176
1177#[cfg(test)]
1178#[allow(clippy::items_after_statements)]
1179#[path = "scope_tests.rs"]
1180mod tests;