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