Skip to main content

turbo_tasks/
scope_unbounded.rs

1//! Unbounded scoped parallelism: enables running jobs that can discover and enqueue more work
2
3use std::{
4    any::Any,
5    ops::ControlFlow,
6    panic::{self, AssertUnwindSafe, catch_unwind},
7    sync::{
8        Arc, OnceLock, SyncView,
9        atomic::{AtomicBool, AtomicUsize, Ordering},
10        mpmc,
11    },
12    time::Duration,
13};
14
15use fixedbitset::FixedBitSet;
16use parking_lot::{Condvar, Mutex, RwLock};
17use tokio::{runtime::Handle, task::AbortHandle};
18use tracing::{Span, info_span};
19
20use crate::{TurboTasksApi, manager::try_turbo_tasks, turbo_tasks_scope};
21
22/// How long a scope worker waits on an empty queue before exiting.
23///
24/// Optimizes respawning which triggers overhead managing WorkerSlots and the accumulator variables
25/// pausing for a short time is worthwhile to avoid that.
26const WORKER_IDLE_TIMEOUT: Duration = Duration::from_micros(100);
27
28/// Runs `run` over `initial` and everything it transitively spawns, returning once every item has
29/// been processed. No results are collected; jobs communicate through state captured in `run`. Use
30/// [`scope_unbounded_with`] to accumulate a value instead.
31//////
32/// Items must be `'static` (they sit in a queue drained by other threads); the `run` closure may
33/// borrow `'env` data.
34///
35/// # Aborting
36///
37/// Both [`ControlFlow::Break`] and a panic abandon all queued-but-unstarted items, so the scope
38/// returns as soon as the currently-running jobs finish. Jobs already in flight on other threads
39/// are **not** interrupted in either case.
40pub fn scope_unbounded<'env, T, F>(initial: impl IntoIterator<Item = T>, run: F)
41where
42    T: Send + 'static,
43    F: Fn(&Scope<'_, T, ()>, T) -> ControlFlow<()> + Send + Sync + 'env,
44{
45    scope_unbounded_with(
46        initial,
47        || (),
48        |spawner, item, ()| run(spawner, item),
49        |(), ()| (),
50    )
51}
52
53/// [`scope_unbounded`], plus a per-drainer accumulator folded into a single return value.
54///
55/// Each drainer builds its own accumulator with `init`, `run` mutates it in place while processing
56/// items, and the accumulators are combined pairwise with `merge` as drainers finish. `merge` must
57/// be associative and commutative — drainers finish in a nondeterministic order, so the grouping
58/// and ordering of the folds are not specified.
59///
60/// This exists so `run` can efficiently accumulate with minimal locking overhead managed by the
61/// scope.
62///
63/// `init` is called potentially many times for each thread context.
64///
65/// Returns `init()` when no item is ever processed (e.g. an empty `initial`).
66///
67/// # Panics and aborts
68///
69/// Both [`ControlFlow::Break`] and a panic abort the scope, abandoning every queued-but-unstarted
70/// item (see [`scope_unbounded`]). They differ in what comes back:
71///
72/// - On `Break`, results accumulated before the abort are returned as usual; the abandoned items
73///   simply never contributed.
74/// - On a panic, the panic is re-raised after the join and **all accumulated results are
75///   discarded** — the return value is only produced on the normal path.
76pub fn scope_unbounded_with<'env, T, R, F, Init, Merge>(
77    initial: impl IntoIterator<Item = T>,
78    init: Init,
79    run: F,
80    merge: Merge,
81) -> R
82where
83    T: Send + 'static,
84    R: Send + 'env,
85    F: Fn(&Scope<'_, T, R>, T, &mut R) -> ControlFlow<()> + Send + Sync + 'env,
86    Init: Fn() -> R + Send + Sync + 'env,
87    Merge: Fn(R, R) -> R + Send + Sync + 'env,
88{
89    let handle = Handle::current();
90    // One worker per runtime thread beyond the calling thread
91    let max_workers = handle.metrics().num_workers().saturating_sub(1);
92    let span = Span::current();
93
94    // `ScopeInner` is parameterized over the borrow lifetime, so these go in as ordinary
95    // references. The one erasure to `'static` is at the tokio hand-off in
96    // `spawn_worker_if_needed`.
97    let init_ref: &(dyn Fn() -> R + Send + Sync + '_) = &init;
98    let merge_ref: &(dyn Fn(R, R) -> R + Send + Sync + '_) = &merge;
99
100    let (sender, receiver) = mpmc::channel();
101    let mut inner = ScopeInner {
102        remaining_tasks: AtomicUsize::new(0),
103        panic: OnceLock::new(),
104        work_queue: receiver,
105        work_queue_sender: RwLock::new(Some(sender)),
106        aborted: AtomicBool::new(false),
107        available_slots: AtomicUsize::new(max_workers),
108        workers: Mutex::new(WorkerSlots::new(max_workers)),
109        handle: handle.clone(),
110        span: span.clone(),
111        workers_idle: Condvar::new(),
112        turbo_tasks: try_turbo_tasks(),
113        run: &run,
114        results: Mutex::new(None),
115        init: init_ref,
116        merge: merge_ref,
117    };
118
119    // Arm the join guard before anything can spawn, so a worker can never outlive the join.
120    let joiner = Joiner { inner: &inner };
121
122    // Increment remaining tasks to ensure the scope cannot exit before all tasks are enqueued
123    inner.remaining_tasks.fetch_add(1, Ordering::Relaxed);
124    for item in initial {
125        enqueue(&inner, item);
126    }
127
128    // Drain and join before checking for a panic. Every drainer has merged its accumulator by the
129    // time this returns.
130    drop(joiner);
131
132    if let Some(err) = inner.panic.take() {
133        panic::resume_unwind(err.into_inner());
134    }
135
136    inner.results.lock().take().unwrap_or_else(init)
137}
138
139/// Handle passed to the `run` closure of [`scope_unbounded`], used to enqueue additional items into
140/// the same scope.
141pub struct Scope<'scope, T: Send + 'static, R = ()> {
142    inner: &'scope ScopeInner<'scope, T, R>,
143}
144
145impl<T: Send + 'static, R: Send> Scope<'_, T, R> {
146    /// Enqueue another item to be processed by `run`. Callable any number of times from inside
147    /// `run`, on any drainer thread.
148    ///
149    /// Silently drops `item` once the scope has aborted.
150    pub fn spawn(&self, item: T) {
151        enqueue(self.inner, item);
152    }
153}
154
155/// A reference to the shared per-item closure for a [`scope_unbounded`] run. `'run` is the lifetime
156/// of the borrows it captures (`'env` at the call site, erased to `'static` when handed to tokio).
157/// `R` is the per-drainer accumulator threaded through by [`scope_unbounded_with`].
158type RunFn<'run, T, R> =
159    &'run (dyn Fn(&Scope<'_, T, R>, T, &mut R) -> ControlFlow<()> + Send + Sync + 'run);
160
161/// Shared state for a [`scope_unbounded`] run, living on the caller's stack.
162///
163/// `'run` is the lifetime of the borrows held by the `run`/`init`/`merge` closures (`'env` at the
164/// call site). It stays a real lifetime here rather than being pinned to `'static` so the fields
165/// don't each force `R: 'static`; the single erasure to `'static` happens at the [`Drainable`]
166/// hand-off to tokio.
167struct ScopeInner<'run, T: Send + 'static, R> {
168    /// Items enqueued but not yet finished. The scope is done exactly when this reaches zero; see
169    /// [`enqueue`] for the increment-before-push ordering that makes zero reliable.
170    remaining_tasks: AtomicUsize,
171    /// First panic raised while processing an item; propagated to the caller after the join.
172    panic: OnceLock<SyncView<Box<dyn Any + Send + 'static>>>,
173    /// Receiving end of the work queue, shared by every drainer.
174    work_queue: mpmc::Receiver<T>,
175    /// Sending end of the queue, modeled so we can `take` and thus close the queue
176    work_queue_sender: RwLock<Option<mpmc::Sender<T>>>,
177    aborted: AtomicBool,
178    /// Spawn budget, mutated under the workers slot, atomic so it can be read outside of it.
179    available_slots: AtomicUsize,
180    workers: Mutex<WorkerSlots>,
181    /// Triggered when the last worker in `workers` is cleared.
182    workers_idle: Condvar,
183    handle: Handle,
184    span: Span,
185    turbo_tasks: Option<Arc<dyn TurboTasksApi>>,
186    /// The per-item closure.
187    run: RunFn<'run, T, R>,
188    init: &'run (dyn Fn() -> R + Send + Sync + 'run),
189    merge: &'run (dyn Fn(R, R) -> R + Send + Sync + 'run),
190    // Accumulated results, workers aggregate into this using init/merge as workers exit their
191    // scope
192    results: Mutex<Option<R>>,
193}
194
195impl<T: Send + 'static, R> ScopeInner<'_, T, R> {
196    /// Closes the work queue by dropping the only sender. Every blocked `recv` returns `Err` once
197    /// this runs and the buffer is drained, which is how drainers learn the scope is finished.
198    /// Idempotent.
199    fn close(&self) {
200        drop(self.work_queue_sender.write().take());
201    }
202
203    /// Abandons all queued-but-unstarted work. Idempotent.
204    fn abort(&self) {
205        self.aborted.store(true, Ordering::Release);
206        self.close();
207    }
208
209    fn on_item_finished(&self) {
210        if self.remaining_tasks.fetch_sub(1, Ordering::Release) == 1 {
211            self.close();
212        }
213    }
214
215    /// Keeps the first panic seen; later ones are dropped.
216    fn record_panic(&self, err: Box<dyn Any + Send + 'static>) {
217        self.abort();
218        let _ = self.panic.set(SyncView::new(err));
219    }
220
221    /// Drain loop, run by both the workers and the calling thread until the scope terminates.
222    ///
223    /// - A scope worker (`is_worker`) exits after [`WORKER_IDLE_TIMEOUT`] on an empty queue
224    /// - The calling thread blocks until the queue closes, which requires every item to be
225    ///   finished.
226    fn drain(&self, is_worker: bool) {
227        if is_worker && let Some(turbo_tasks) = &self.turbo_tasks {
228            turbo_tasks_scope(turbo_tasks.clone(), || self.drain_loop(is_worker))
229        } else {
230            self.drain_loop(is_worker)
231        }
232    }
233
234    fn drain_loop(&self, is_worker: bool) {
235        let mut acc: Option<R> = None;
236        while let Some(item) = if is_worker {
237            self.work_queue.recv_timeout(WORKER_IDLE_TIMEOUT).ok()
238        } else {
239            self.work_queue.recv().ok()
240        } {
241            // Post-abort: discard without running, so the wind-down can't re-grow the queue.
242            if self.aborted.load(Ordering::Acquire) {
243                self.on_item_finished();
244                continue;
245            }
246            let spawner = Scope { inner: self };
247            let result = catch_unwind(AssertUnwindSafe(|| {
248                // Lazily init the thread local accumulator only when we are going to execute an
249                // item
250                let acc = acc.get_or_insert_with(self.init);
251                (self.run)(&spawner, item, acc)
252            }));
253
254            match result {
255                Ok(ControlFlow::Continue(())) => {}
256                Ok(ControlFlow::Break(())) => {
257                    self.abort();
258                }
259                // A panic aborts too; see `scope_unbounded`.
260                Err(panic) => {
261                    self.record_panic(panic);
262                }
263            };
264            self.on_item_finished();
265        }
266
267        // Fold this drainer's accumulator into the shared results slot
268        if let Some(acc) = acc {
269            let merged = catch_unwind(AssertUnwindSafe(|| {
270                let mut results = self.results.lock();
271                *results = Some(match results.take() {
272                    Some(existing) => (self.merge)(existing, acc),
273                    None => acc,
274                });
275            }));
276            if let Err(panic) = merged {
277                self.record_panic(panic);
278            }
279        }
280    }
281}
282
283/// Account for and enqueue one item. The increment must happen before the push: pushing first would
284/// let another drainer pop and finish the item before it is counted, so `remaining_tasks` could hit
285/// zero with work still live.
286fn enqueue<T: Send + 'static, R: Send>(inner: &ScopeInner<'_, T, R>, item: T) {
287    if inner.aborted.load(Ordering::Acquire) {
288        return;
289    }
290    let num_tasks = inner.remaining_tasks.fetch_add(1, Ordering::Relaxed) + 1;
291    let sent = {
292        let sender = inner.work_queue_sender.read();
293        match sender.as_ref() {
294            Some(sender) => sender.send(item).is_ok(),
295            // Closed: the scope is winding down (aborted, or already finished).
296            None => false,
297        }
298    };
299    if !sent {
300        inner.on_item_finished(); // since the item won't execute decrement now
301        return;
302    }
303    spawn_worker_if_needed(inner, num_tasks);
304}
305
306/// Re-arm one worker if the scope is running below its budget.
307fn spawn_worker_if_needed<T: Send + 'static, R: Send>(
308    inner: &ScopeInner<'_, T, R>,
309    num_enqueued_tasks: usize,
310) {
311    if num_enqueued_tasks <= 1
312        || inner.available_slots.load(Ordering::Relaxed) == 0
313        || inner.aborted.load(Ordering::Acquire)
314    {
315        return;
316    }
317
318    // SAFETY: `Joiner::drop` waits for every worker slot to be released before returning, and the
319    // slot for this worker is claimed below under the table lock *before* the spawn, so no erased
320    // reference can outlive `'env` or the `inner` stack slot.
321    let erased: &(dyn Drainable + Send + Sync + '_) = inner;
322    let erased: &'static (dyn Drainable + Send + Sync + 'static) = unsafe {
323        std::mem::transmute::<
324            &(dyn Drainable + Send + Sync + '_),
325            &'static (dyn Drainable + Send + Sync + 'static),
326        >(erased)
327    };
328
329    let mut slots = inner.workers.lock();
330    let Some(slot) = slots.free_slot() else {
331        return;
332    };
333    let span = inner.span.clone();
334    // capture before the spawn and move into it
335    let guard = erased.claim_worker_slot(slot);
336    let handle = inner
337        .handle
338        .spawn(async move {
339            let _span = span.entered();
340            let _guard = guard;
341            erased.drain(true);
342        })
343        .abort_handle();
344    slots.occupy(slot, handle, &inner.available_slots);
345}
346
347/// The drain loop with the accumulator type erased.
348///
349/// Worker tasks are spawned onto tokio and so must be `'static`, but the accumulator `R` borrows
350/// `'env`. A worker only ever needs to *run* the loop — it never names an `R` — so it holds the
351/// scope through this trait instead of the concrete [`ScopeInner`], keeping `R` out of the spawned
352/// future's type entirely.
353trait Drainable {
354    fn drain(&self, is_worker: bool);
355    /// Take ownership of `slot`'s release, which [`spawn_worker_if_needed`] has already claimed.
356    /// On the trait so a spawned worker can build its guard without naming `R`.
357    fn claim_worker_slot(&self, slot: usize) -> WorkerGuard<'_>;
358}
359
360impl<T: Send + 'static, R> Drainable for ScopeInner<'_, T, R> {
361    fn drain(&self, is_worker: bool) {
362        ScopeInner::drain(self, is_worker)
363    }
364
365    fn claim_worker_slot(&self, slot: usize) -> WorkerGuard<'_> {
366        WorkerGuard {
367            slots: &self.workers,
368            workers_idle: &self.workers_idle,
369            available_slots: &self.available_slots,
370            slot,
371        }
372    }
373}
374
375/// Fixed table of worker slots, indexed by slot number, with a bitset of which are occupied.
376///
377/// This is both the spawn budget and the join set, because a slot is occupied from *before* its
378/// task exists until *after* that task's last access to the caller's frame:
379struct WorkerSlots {
380    handles: Vec<Option<AbortHandle>>,
381    /// Bit `i` set means slot `i` is occupied — claimed by [`Self::occupy`] and not yet released
382    /// by a [`WorkerGuard`]. Tracks *occupancy*, not handle presence.
383    occupied: FixedBitSet,
384}
385
386impl WorkerSlots {
387    fn new(max_workers: usize) -> Self {
388        Self {
389            handles: vec![None; max_workers],
390            occupied: FixedBitSet::with_capacity(max_workers),
391        }
392    }
393
394    fn free_slot(&self) -> Option<usize> {
395        self.occupied.zeroes().next()
396    }
397
398    /// Record a newly spawned worker in `slot`, which must have come from [`Self::free_slot`].
399    fn occupy(&mut self, slot: usize, handle: AbortHandle, available_slots: &AtomicUsize) {
400        debug_assert!(
401            !self.occupied.contains(slot),
402            "slot {slot} already occupied"
403        );
404        available_slots.fetch_sub(1, Ordering::Relaxed);
405        self.occupied.insert(slot);
406        let previous = self.handles[slot].replace(handle);
407        debug_assert!(previous.is_none(), "slot {slot} held a live handle");
408    }
409
410    /// Dropping the handle here keeps the finished task's allocation from outliving the worker.
411    fn release(&mut self, slot: usize, available_slots: &AtomicUsize) {
412        available_slots.fetch_add(1, Ordering::Relaxed);
413        self.occupied.remove(slot);
414        self.handles[slot] = None;
415    }
416
417    /// Whether every slot is free, i.e. no task can still touch the caller's frame. The predicate
418    /// [`Joiner::drop`] waits on.
419    fn is_idle(&self) -> bool {
420        self.occupied.is_clear()
421    }
422}
423
424/// Releases a worker's slot and wakes [`Joiner::drop`] when it is the last one.
425struct WorkerGuard<'a> {
426    slots: &'a Mutex<WorkerSlots>,
427    workers_idle: &'a Condvar,
428    available_slots: &'a AtomicUsize,
429    slot: usize,
430}
431
432impl Drop for WorkerGuard<'_> {
433    fn drop(&mut self) {
434        let mut slots_guard = self.slots.lock();
435        slots_guard.release(self.slot, self.available_slots);
436        if slots_guard.is_idle() {
437            // Still holding the lock: the predicate the joiner waits on is read under this same
438            // lock, so it cannot go true between its check and its `wait`.
439            self.workers_idle.notify_all();
440        }
441    }
442}
443
444/// Drains the queue and joins the workers, on the return path and on an unwind alike.
445struct Joiner<'a, 'run, T: Send + 'static, R> {
446    inner: &'a ScopeInner<'run, T, R>,
447}
448
449impl<T: Send + 'static, R> Drop for Joiner<'_, '_, T, R> {
450    fn drop(&mut self) {
451        // Discharge the placeholder item that covered the seeding loop.
452        self.inner.on_item_finished();
453        // Returns only once the queue is closed, so no new work can arrive after this.
454        self.inner.drain(false);
455        // The queue is now closed so no workers can spawn. Capture all the handles.
456        // There should be no contention on this slot
457        let _span = info_span!("blocking: waiting for scope to end").entered();
458        let handles: Vec<_> = {
459            let mut slots = self.inner.workers.lock();
460            slots.handles.iter_mut().filter_map(Option::take).collect()
461        };
462
463        // Abort all workers, that way workers we have spawned but have never run get dropped and
464        // release their slots.  Otherwise a contended runtime could delay shutdown of the scope.
465        for handle in handles {
466            handle.abort();
467        }
468
469        // Wait for all workers to exit and drop their guards.  This should be fast, after the
470        // aborts and the queue is dropped each worker just needs to merge their accumulated
471        // stats.  So we wait for that.
472        let mut slots = self.inner.workers.lock();
473        while !slots.is_idle() {
474            self.inner.workers_idle.wait(&mut slots);
475        }
476    }
477}
478#[cfg(test)]
479mod tests {
480    use std::{
481        sync::{Arc, atomic::AtomicUsize},
482        thread,
483        time::Duration,
484    };
485
486    use super::*;
487
488    /// Runs `body` on a runtime with the I/O driver disabled, so the test also works under Miri.
489    ///
490    /// `#[tokio::test]` hardcodes `Builder::enable_all()`, whose I/O driver calls
491    /// `kqueue()`/`epoll_create1()` — Miri has no shim for either, so such a test aborts at runtime
492    /// construction before reaching any code under test. Nothing here needs I/O.
493    ///
494    /// `body` runs on a blocking thread, as production callers are expected to: `scope_unbounded`
495    /// blocks, and its `block_in_place` requires a multi-thread runtime.
496    fn with_runtime<F, T>(worker_threads: usize, body: F) -> T
497    where
498        F: FnOnce() -> T + Send + 'static,
499        T: Send + 'static,
500    {
501        let runtime = tokio::runtime::Builder::new_multi_thread()
502            .worker_threads(worker_threads)
503            .enable_time()
504            .build()
505            .unwrap();
506        runtime.block_on(async { tokio::task::spawn_blocking(body).await.unwrap() })
507    }
508
509    /// A single `run` call enqueues a large batch of leaves; every one must be processed.
510    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
511    async fn test_unbounded_wide_burst_of_leaves() {
512        const CHILDREN: usize = 1000;
513        let processed = Arc::new(AtomicUsize::new(0));
514        let processed_clone = processed.clone();
515        tokio::task::spawn_blocking(move || {
516            scope_unbounded(std::iter::once(0usize), move |spawner, item| {
517                processed_clone.fetch_add(1, Ordering::SeqCst);
518                if item == 0 {
519                    // The root fans out to CHILDREN leaves.
520                    for i in 0..CHILDREN {
521                        spawner.spawn(1 + i);
522                    }
523                }
524                ControlFlow::Continue(())
525            });
526        })
527        .await
528        .unwrap();
529        // 1 root + CHILDREN leaves.
530        assert_eq!(processed.load(Ordering::SeqCst), 1 + CHILDREN);
531    }
532
533    /// A slow seeding iterator must not let the scope finish early.
534    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
535    async fn test_unbounded_slow_seeding_iterator_completes() {
536        const SEEDS: usize = 16;
537        let processed = Arc::new(AtomicUsize::new(0));
538        let processed_clone = processed.clone();
539        tokio::task::spawn_blocking(move || {
540            // Each `next()` blocks briefly, so the queue drains to empty before the next seed
541            // arrives.
542            let slow_seeds = std::iter::from_fn({
543                let mut next = 0;
544                move || {
545                    if next == SEEDS {
546                        return None;
547                    }
548                    thread::sleep(Duration::from_millis(2));
549                    next += 1;
550                    Some(next - 1)
551                }
552            });
553            scope_unbounded(slow_seeds, move |_spawner, _item| {
554                processed_clone.fetch_add(1, Ordering::SeqCst);
555                ControlFlow::Continue(())
556            });
557        })
558        .await
559        .unwrap();
560        assert_eq!(
561            processed.load(Ordering::SeqCst),
562            SEEDS,
563            "seeds produced after the queue briefly drained must still be processed"
564        );
565    }
566
567    /// Aborting in the middle of a deep, still-growing cascade must terminate rather than hang:
568    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
569    #[cfg_attr(
570        target_family = "wasm",
571        ignore = "tokio runtime shutdown hangs on wasm while blocking threads are live"
572    )]
573    async fn test_unbounded_abort_during_cascade() {
574        // Each item spawns two children until the id exceeds the bound, so the queue is still
575        // growing when the abort lands.
576        const MAX_ID: usize = 1 << 14;
577        let processed = Arc::new(AtomicUsize::new(0));
578        let processed_clone = processed.clone();
579        tokio::task::spawn_blocking(move || {
580            scope_unbounded(std::iter::once(1usize), move |spawner, id| {
581                let n = processed_clone.fetch_add(1, Ordering::SeqCst);
582                if n == 100 {
583                    return ControlFlow::Break(());
584                }
585                let (left, right) = (id * 2, id * 2 + 1);
586                if left <= MAX_ID {
587                    spawner.spawn(left);
588                }
589                if right <= MAX_ID {
590                    spawner.spawn(right);
591                }
592                ControlFlow::Continue(())
593            });
594        })
595        .await
596        .unwrap();
597        let count = processed.load(Ordering::SeqCst);
598        assert!(
599            count < MAX_ID,
600            "abort must cut the cascade short, but {count} items ran"
601        );
602    }
603
604    /// `spawn` issued *after* the abort has latched must be dropped, not enqueued — the case a job
605    /// finishing concurrently with another job's abort hits. A `spawn` that counted an item into
606    /// `remaining_tasks` without queueing it would never reach zero, so this hangs rather than
607    /// fails.
608    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
609    async fn test_unbounded_spawn_after_abort_is_dropped() {
610        let processed = Arc::new(AtomicUsize::new(0));
611        let processed_clone = processed.clone();
612        const SEEDS: usize = 64;
613        tokio::task::spawn_blocking(move || {
614            scope_unbounded(0..SEEDS, move |spawner, item| {
615                processed_clone.fetch_add(1, Ordering::SeqCst);
616                // Spawning *before* the `Break` is the point: these spawns race the abort latch and
617                // must be dropped rather than counted-but-unqueued.
618                for i in 0..1000 {
619                    spawner.spawn(SEEDS + item * 1000 + i);
620                }
621                ControlFlow::Break(())
622            });
623        })
624        .await
625        .unwrap();
626        // Only seeds may run: every spawned id is >= SEEDS, so processing even one would push the
627        // count past the seed total.
628        let count = processed.load(Ordering::SeqCst);
629        assert!(
630            count <= SEEDS,
631            "post-abort spawns must be dropped, but {count} items ran"
632        );
633    }
634
635    /// Abort on a `current_thread` runtime, where the calling thread is the only drainer.
636    #[tokio::test(flavor = "current_thread")]
637    async fn test_unbounded_abort_current_thread_runtime() {
638        let processed = Arc::new(AtomicUsize::new(0));
639        let processed_clone = processed.clone();
640        tokio::task::spawn_blocking(move || {
641            scope_unbounded(0..1000usize, move |spawner, _item| {
642                processed_clone.fetch_add(1, Ordering::SeqCst);
643                spawner.spawn(9999);
644                ControlFlow::Break(())
645            });
646        })
647        .await
648        .unwrap();
649        // With a single drainer the abort lands before any other item is picked up.
650        assert_eq!(processed.load(Ordering::SeqCst), 1);
651    }
652
653    /// A panic that happens while the scope is aborting still propagates rather than being
654    /// swallowed by the wind-down: the abort's queue-clear races the panic's unwind through
655    /// `catch_unwind` -> `on_item_finished`.
656    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
657    #[cfg_attr(target_family = "wasm", ignore = "no unwinding on wasm")]
658    async fn test_unbounded_abort_then_panic() {
659        let result = catch_unwind(AssertUnwindSafe(|| {
660            scope_unbounded(0..1000usize, |_spawner, item| {
661                if item == 0 {
662                    panic!("Intentional panic");
663                }
664                ControlFlow::Break(())
665            });
666            unreachable!();
667        }));
668        let err = result.expect_err("the panic must propagate even though the scope aborted");
669        assert_eq!(err.downcast_ref::<&str>(), Some(&"Intentional panic"));
670    }
671
672    /// A panic in a `run` invocation propagates after all in-flight work is joined, and aborts the
673    /// scope: the queued-but-unstarted items are abandoned rather than run.
674    ///
675    /// Use a growing cascade and panic after enough work has run to guarantee that work remains
676    /// queued. A fixed seed set can drain completely before its first item panics, making it unable
677    /// to distinguish a missed abort from valid scheduling.
678    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
679    #[cfg_attr(target_family = "wasm", ignore = "no unwinding on wasm")]
680    async fn test_unbounded_panic_propagates_and_abandons_queue() {
681        const MAX_ID: usize = 1 << 14;
682        let processed = Arc::new(AtomicUsize::new(0));
683        let processed_clone = processed.clone();
684        let result = catch_unwind(AssertUnwindSafe(|| {
685            scope_unbounded(std::iter::once(1usize), move |spawner, id| {
686                let n = processed_clone.fetch_add(1, Ordering::SeqCst);
687                if n == 100 {
688                    panic!("Intentional panic");
689                }
690                let (left, right) = (id * 2, id * 2 + 1);
691                if left <= MAX_ID {
692                    spawner.spawn(left);
693                }
694                if right <= MAX_ID {
695                    spawner.spawn(right);
696                }
697                ControlFlow::Continue(())
698            });
699            unreachable!();
700        }));
701        let err = result.expect_err("the panic must propagate");
702        assert_eq!(err.downcast_ref::<&str>(), Some(&"Intentional panic"));
703        let count = processed.load(Ordering::SeqCst);
704        assert!(
705            count < MAX_ID,
706            "a panic must cut the cascade short, but {count} items ran"
707        );
708    }
709
710    // -----------------------------------------------------------------------
711    // scope_unbounded_with (fold results)
712    // -----------------------------------------------------------------------
713
714    /// The accumulator must be per-drainer, not shared: collecting into a `Vec` and merging by
715    /// concatenation must preserve every element even with several drainers running.
716    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
717    async fn test_unbounded_with_collects_all_values() {
718        const ITEMS: usize = 500;
719        let mut collected = tokio::task::spawn_blocking(|| {
720            scope_unbounded_with(
721                0..ITEMS,
722                Vec::new,
723                |_spawner, item: usize, acc: &mut Vec<usize>| {
724                    acc.push(item);
725                    ControlFlow::Continue(())
726                },
727                |mut a: Vec<usize>, b| {
728                    a.extend(b);
729                    a
730                },
731            )
732        })
733        .await
734        .unwrap();
735        collected.sort_unstable();
736        assert_eq!(collected, (0..ITEMS).collect::<Vec<_>>());
737    }
738
739    /// With no items, no drainer builds an accumulator, so the result is exactly one `init()` —
740    /// not a fold of one per drainer that happened to start.
741    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
742    async fn test_unbounded_with_empty_returns_init() {
743        let total = tokio::task::spawn_blocking(|| {
744            scope_unbounded_with(
745                std::iter::empty::<usize>(),
746                || 42usize,
747                |_spawner, _item, _acc| ControlFlow::Continue(()),
748                |a, b| a + b,
749            )
750        })
751        .await
752        .unwrap();
753        assert_eq!(total, 42, "expected exactly one init(), got {total}");
754    }
755
756    /// A scope driven **directly** on a `current_thread` runtime — not via `spawn_blocking` — must
757    /// still complete.
758    #[tokio::test(flavor = "current_thread")]
759    async fn test_unbounded_current_thread_direct_call_completes() {
760        let processed = Arc::new(AtomicUsize::new(0));
761        let processed_clone = processed.clone();
762        scope_unbounded(0..8usize, move |spawner, item| {
763            processed_clone.fetch_add(1, Ordering::SeqCst);
764            if item < 3 {
765                spawner.spawn(100 + item);
766            }
767            ControlFlow::Continue(())
768        });
769        assert_eq!(processed.load(Ordering::SeqCst), 11);
770    }
771
772    /// Aborting returns the results accumulated up to that point rather than discarding them —
773    /// only the abandoned items are missing. The run must still terminate cleanly.
774    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
775    async fn test_unbounded_with_abort_returns_partial_results() {
776        let processed = tokio::task::spawn_blocking(|| {
777            scope_unbounded_with(
778                0..1000usize,
779                || 0usize,
780                |_spawner, item, acc| {
781                    *acc += 1;
782                    if item == 0 {
783                        return ControlFlow::Break(());
784                    }
785                    ControlFlow::Continue(())
786                },
787                |a, b| a + b,
788            )
789        })
790        .await
791        .unwrap();
792        // At least the aborting item ran, and the abort must have cut the run short.
793        assert!(processed >= 1, "expected the aborting item to be counted");
794        assert!(
795            processed < 1000,
796            "abort should abandon queued items, but all {processed} ran"
797        );
798    }
799
800    /// A panic must propagate through the fold path without deadlocking the join, which drainers
801    /// reach only after their merge.
802    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
803    #[cfg_attr(target_family = "wasm", ignore = "no unwinding on wasm")]
804    async fn test_unbounded_with_panic_propagates() {
805        let result = catch_unwind(AssertUnwindSafe(|| {
806            scope_unbounded_with(
807                0..100usize,
808                || 0usize,
809                |_spawner, item, acc| {
810                    if item == 50 {
811                        panic!("Intentional panic");
812                    }
813                    *acc += 1;
814                    ControlFlow::Continue(())
815                },
816                |a, b| a + b,
817            );
818            unreachable!();
819        }));
820        let err = result.expect_err("the panic must propagate out of the fold API");
821        assert_eq!(err.downcast_ref::<&str>(), Some(&"Intentional panic"));
822    }
823
824    /// The accumulator may borrow `'env` data (it is not `'static`), mirroring how `run` may.
825    #[test]
826    fn test_unbounded_with_borrowed_accumulator() {
827        let label = String::from("item");
828        let count = with_runtime(4, move || {
829            let label = &label;
830            scope_unbounded_with(
831                0..32usize,
832                Vec::new,
833                |_spawner, item: usize, acc: &mut Vec<String>| {
834                    acc.push(format!("{label}-{item}"));
835                    ControlFlow::Continue(())
836                },
837                |mut a: Vec<String>, b| {
838                    a.extend(b);
839                    a
840                },
841            )
842            .len()
843        });
844        assert_eq!(count, 32);
845    }
846
847    // -----------------------------------------------------------------------
848    // worker exit / respawn tests
849    //
850    // `init` runs once per drainer that receives an item, so counting `init` calls counts *distinct
851    // drainer lifetimes* — the only externally visible signal that a worker exited and a later one
852    // replaced it.
853    // -----------------------------------------------------------------------
854
855    /// A worker exits once the queue is empty, and a later `spawn` re-arms one: the scope must
856    /// still finish work produced after every worker has gone away.
857    ///
858    /// Serialized by construction — one item in flight at a time, with a gap long enough that any
859    /// worker has certainly timed out — so reaching the second item at all exercises the respawn
860    /// path rather than a still-live worker.
861    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
862    async fn test_unbounded_worker_respawns_after_going_idle() {
863        let inits = Arc::new(AtomicUsize::new(0));
864        let counted = inits.clone();
865        let processed = Arc::new(AtomicUsize::new(0));
866        let ran = processed.clone();
867        tokio::task::spawn_blocking(move || {
868            scope_unbounded_with(
869                std::iter::once(0usize),
870                move || {
871                    counted.fetch_add(1, Ordering::SeqCst);
872                },
873                move |spawner, item, ()| {
874                    ran.fetch_add(1, Ordering::SeqCst);
875                    if item == 0 {
876                        // Let the queue sit empty long enough that any worker has exited, then
877                        // produce work again. A fresh drainer is the only thing that can pick it
878                        // up.
879                        thread::sleep(Duration::from_millis(100));
880                        spawner.spawn(1);
881                    }
882                    ControlFlow::Continue(())
883                },
884                |(), ()| (),
885            )
886        })
887        .await
888        .unwrap();
889        assert_eq!(
890            processed.load(Ordering::SeqCst),
891            2,
892            "work spawned after the pool went idle must still run"
893        );
894        // Both items ran (the scope only returns once the queue is drained), and at least one
895        // drainer built an accumulator. The exact count depends on which drainer wins each item.
896        let count = inits.load(Ordering::SeqCst);
897        assert!(
898            (1..=2).contains(&count),
899            "expected 1-2 drainer lifetimes, got {count}"
900        );
901    }
902
903    /// A scope that never has queued work must not occupy a worker at all.
904    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
905    async fn test_unbounded_empty_spawns_no_workers() {
906        let inits = Arc::new(AtomicUsize::new(0));
907        let counted = inits.clone();
908        tokio::task::spawn_blocking(move || {
909            scope_unbounded_with(
910                std::iter::empty::<usize>(),
911                move || counted.fetch_add(1, Ordering::SeqCst),
912                |_spawner, _item, _acc| ControlFlow::Continue(()),
913                |a, b| a + b,
914            )
915        })
916        .await
917        .unwrap();
918        assert_eq!(
919            inits.load(Ordering::SeqCst),
920            1,
921            "expected only the terminal identity init(), not a per-drainer one"
922        );
923    }
924
925    /// Workers are not torn down while work is still queued.
926    ///
927    /// The queue is kept saturated deterministically instead of by racing a producer, because a
928    /// preempted producer would let workers hit `WORKER_IDLE_TIMEOUT` and be re-armed:
929    ///
930    /// - Every parent (initial item) blocks until the `initial` iterator is exhausted. While the
931    ///   calling thread is still enqueueing, each live worker is therefore busy inside `run`.
932    /// - Each parent then spawns one child. Children block until every parent's `spawn` returns.
933    ///   The queue is FIFO, so every parent is already in flight by the time any child is held;
934    ///   this cannot deadlock. While a `spawn` is still pending, every drainer is either holding an
935    ///   item or has thousands of children left to pop, so no worker can find the queue empty.
936    ///
937    /// A correct implementation never re-arms a worker, whereas one that drops workers while work
938    /// is still queued is re-armed by later `spawn` calls and churns.
939    ///
940    /// This does not time `WORKER_IDLE_TIMEOUT` itself; that cannot be asserted without depending
941    /// on scheduler timing.
942    #[cfg_attr(
943        target_family = "wasm",
944        ignore = "intermittently stalls under the full wasm suite"
945    )]
946    // This test is too slow to run under Miri.
947    #[cfg(not(miri))]
948    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
949    async fn test_unbounded_busy_queue_does_not_churn_workers() {
950        // Total items processed: `ITEMS / 2` initial items, each spawning one child.
951        const ITEMS: usize = 20_000;
952        const INITIAL: usize = ITEMS / 2;
953        let inits = Arc::new(AtomicUsize::new(0));
954        let counted = inits.clone();
955        // Set once the calling thread has enqueued every parent.
956        let released = Arc::new(AtomicBool::new(false));
957        let release = released.clone();
958        // Number of parents whose `spawn` call has returned.
959        let parents_spawned = AtomicUsize::new(0);
960        let processed = tokio::task::spawn_blocking(move || {
961            scope_unbounded_with(
962                (0..INITIAL).chain(std::iter::from_fn(move || {
963                    release.store(true, Ordering::Release);
964                    None
965                })),
966                move || {
967                    counted.fetch_add(1, Ordering::SeqCst);
968                    0usize
969                },
970                move |spawner, item, acc| {
971                    if item < INITIAL {
972                        while !released.load(Ordering::Acquire) {
973                            thread::yield_now();
974                        }
975                        spawner.spawn(item + INITIAL);
976                        parents_spawned.fetch_add(1, Ordering::Release);
977                    } else {
978                        while parents_spawned.load(Ordering::Acquire) < INITIAL {
979                            thread::yield_now();
980                        }
981                    }
982                    *acc += 1;
983                    ControlFlow::Continue(())
984                },
985                |a, b| a + b,
986            )
987        })
988        .await
989        .unwrap();
990        assert_eq!(processed, ITEMS, "every item must run");
991        // 4 runtime workers => at most 3 scope workers + the calling thread. A worker that has
992        // received an item only exits once the queue is empty, which cannot happen until the last
993        // `spawn` has been made. Each drainer slot therefore hosts at most one lifetime that
994        // receives an item.
995        let count = inits.load(Ordering::SeqCst);
996        assert!(
997            count <= 4,
998            "a saturated queue should not churn drainers, got {count} lifetimes for {ITEMS} items"
999        );
1000    }
1001
1002    /// The join must not depend on tokio scheduling, even when every runtime thread is contended
1003    /// and workers are still mid-drain.
1004    #[test]
1005    fn test_unbounded_join_under_thread_starvation() {
1006        let runtime = tokio::runtime::Builder::new_multi_thread()
1007            .worker_threads(2)
1008            .enable_time()
1009            .build()
1010            .unwrap();
1011        runtime.block_on(async {
1012            let mut scopes = Vec::new();
1013            for _ in 0..8 {
1014                scopes.push(tokio::task::spawn_blocking(|| {
1015                    let processed = Arc::new(AtomicUsize::new(0));
1016                    let counted = processed.clone();
1017                    scope_unbounded(0..200usize, move |spawner, item| {
1018                        counted.fetch_add(1, Ordering::SeqCst);
1019                        // Keep the queue growing so workers are still draining at join time.
1020                        if item < 200 {
1021                            spawner.spawn(1000 + item);
1022                        }
1023                        thread::yield_now();
1024                        ControlFlow::Continue(())
1025                    });
1026                    processed.load(Ordering::SeqCst)
1027                }));
1028            }
1029            for scope in scopes {
1030                assert_eq!(scope.await.unwrap(), 400, "every scope must drain fully");
1031            }
1032        });
1033    }
1034}