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}