use std::future::Future;
use std::task::Poll;
use crate::cx::{Cx, Scope};
use crate::runtime::JoinError;
use crate::runtime::TaskHandle;
use crate::runtime::state::SpawnError;
use crate::types::policy::FailFast;
use crate::types::{CancelReason, Outcome, PanicPayload, Policy, Severity};
pub struct JoinSet<'scope, T, E, P>
where
P: Policy,
{
scope: Scope<'scope, P>,
handles: Vec<TaskHandle<Result<T, E>>>,
summary: JoinSummary,
set_id: u64,
next_member_index: u64,
}
impl<'scope, T, E, P> JoinSet<'scope, T, E, P>
where
P: Policy,
T: Send + 'static,
E: Send + 'static,
{
#[must_use]
pub fn new(scope: &'scope Scope<'scope, P>) -> Self {
Self {
scope: clone_scope(scope),
handles: Vec::new(),
summary: JoinSummary::default(),
set_id: scope.region_id().as_u64(),
next_member_index: 0,
}
}
pub fn spawn<F, Fut>(&mut self, cx: &Cx, f: F) -> Result<(), SpawnError>
where
F: FnOnce(Cx) -> Fut + Send + 'static,
Fut: Future<Output = Result<T, E>> + Send + 'static,
{
let member_index = self.next_member_index;
let handle = cx.spawn_in(&self.scope, f)?;
self.handles.push(handle);
self.next_member_index = self.next_member_index.saturating_add(1);
self.trace_member_spawn(cx, member_index, "send");
Ok(())
}
pub fn spawn_local<F, Fut>(&mut self, cx: &Cx, f: F) -> Result<(), SpawnError>
where
F: FnOnce(Cx) -> Fut + 'static,
Fut: Future<Output = Result<T, E>> + 'static,
{
let member_index = self.next_member_index;
let handle = cx.spawn_local_in(&self.scope, f)?;
self.handles.push(handle);
self.next_member_index = self.next_member_index.saturating_add(1);
self.trace_member_spawn(cx, member_index, "local");
Ok(())
}
#[must_use]
pub fn len(&self) -> usize {
self.handles.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.handles.is_empty()
}
pub fn try_join_next(&mut self) -> Option<Outcome<T, E>> {
for index in 0..self.handles.len() {
let outcome = try_join_to_outcome(self.handles[index].try_join());
if let Some(outcome) = outcome {
self.summary.record(&outcome);
self.handles.remove(index);
return Some(outcome);
}
}
None
}
pub async fn join_all(mut self, cx: &Cx) -> Vec<Outcome<T, E>> {
self.drain_all(cx).await
}
pub async fn join_next(&mut self, cx: &Cx) -> Option<Outcome<T, E>> {
if let Some(outcome) = self.try_join_next() {
return Some(outcome);
}
std::future::poll_fn(|task_cx| {
if let Some(outcome) = self.try_join_next() {
return Poll::Ready(Some(outcome));
}
if self.handles.is_empty() {
return Poll::Ready(None);
}
for index in 0..self.handles.len() {
let joined = {
let handle = &mut self.handles[index];
let mut join = handle.join(cx);
match std::pin::Pin::new(&mut join).poll(task_cx) {
Poll::Ready(joined) => Some(joined),
Poll::Pending => {
join.defuse_drop_abort();
None
}
}
};
if let Some(joined) = joined {
let outcome = join_to_outcome(joined);
self.summary.record(&outcome);
self.handles.remove(index);
return Poll::Ready(Some(outcome));
}
}
Poll::Pending
})
.await
}
pub async fn cancel_all(self, cx: &Cx) -> Vec<Outcome<T, E>> {
self.cancel_all_with_reason(cx, CancelReason::user("abort"))
.await
}
pub async fn cancel_all_with_reason(
mut self,
cx: &Cx,
reason: CancelReason,
) -> Vec<Outcome<T, E>> {
for handle in &self.handles {
handle.abort_with_reason(reason.clone());
}
self.drain_all(cx).await
}
#[must_use]
pub fn summary(&self) -> JoinSummary {
self.summary
}
async fn drain_all(&mut self, cx: &Cx) -> Vec<Outcome<T, E>> {
let mut handles = std::mem::take(&mut self.handles);
let mut outcomes = Vec::with_capacity(handles.len());
for handle in &mut handles {
let outcome = join_to_outcome(handle.join(cx).await);
self.summary.record(&outcome);
outcomes.push(outcome);
}
outcomes
}
fn trace_member_spawn(&self, cx: &Cx, member_index: u64, spawn_kind: &str) {
let set_id = self.set_id.to_string();
let member_index = member_index.to_string();
let region = self.scope.region_id().to_string();
let active_members = self.handles.len().to_string();
cx.trace_with_fields(
"join_set.spawn",
&[
("join_set_id", set_id.as_str()),
("member_index", member_index.as_str()),
("region", region.as_str()),
("spawn_kind", spawn_kind),
("active_members", active_members.as_str()),
],
);
}
}
impl<T, E> JoinSet<'static, T, E, FailFast>
where
T: Send + 'static,
E: Send + 'static,
{
#[must_use]
pub fn in_cx(cx: &Cx) -> Self {
Self {
scope: cx.scope(),
handles: Vec::new(),
summary: JoinSummary::default(),
set_id: cx.region_id().as_u64(),
next_member_index: 0,
}
}
}
impl<T, E, P> Drop for JoinSet<'_, T, E, P>
where
P: Policy,
{
fn drop(&mut self) {
for handle in &self.handles {
handle.abort();
}
}
}
fn join_to_outcome<T, E>(joined: Result<Result<T, E>, JoinError>) -> Outcome<T, E> {
match joined {
Ok(Ok(value)) => Outcome::Ok(value),
Ok(Err(error)) => Outcome::Err(error),
Err(JoinError::Cancelled(reason)) => Outcome::Cancelled(reason),
Err(JoinError::Panicked(payload)) => Outcome::Panicked(payload),
Err(JoinError::PolledAfterCompletion) => {
Outcome::Panicked(PanicPayload::new("join handle polled after completion"))
}
}
}
fn try_join_to_outcome<T, E>(
joined: Result<Option<Result<T, E>>, JoinError>,
) -> Option<Outcome<T, E>> {
match joined {
Ok(Some(result)) => Some(join_to_outcome(Ok(result))),
Ok(None) => None,
Err(error) => Some(join_to_outcome(Err(error))),
}
}
fn clone_scope<'scope, P>(scope: &'scope Scope<'scope, P>) -> Scope<'scope, P>
where
P: Policy,
{
Scope::new_with_capability_budget(scope.region_id(), scope.budget(), scope.capability_budget())
.with_pending_spawn_counter(scope.pending_spawn_counter_handle())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JoinSummary {
completed: usize,
worst: Severity,
}
impl Default for JoinSummary {
fn default() -> Self {
Self {
completed: 0,
worst: Severity::Ok,
}
}
}
impl JoinSummary {
#[must_use]
pub const fn completed(&self) -> usize {
self.completed
}
#[must_use]
pub const fn worst(&self) -> Severity {
self.worst
}
fn record<T, E>(&mut self, outcome: &Outcome<T, E>) {
self.completed += 1;
self.worst = self.worst.max(outcome.severity());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channel::oneshot;
use crate::cx::Cx;
use crate::observability::{LogCollector, LogLevel};
use crate::runtime::{RuntimeBuilder, yield_now};
use crate::types::TaskId;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn run_in_runtime<F, Fut, T>(f: F) -> T
where
F: FnOnce(Cx) -> Fut + Send + 'static,
Fut: Future<Output = T> + Send + 'static,
T: Send + 'static,
{
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("test runtime");
runtime.block_on(runtime.handle().spawn(async move {
let cx = Cx::current().expect("spawned root task has Cx");
f(cx).await
}))
}
fn manual_handle<T>(task_slot: u32) -> (oneshot::Sender<Result<T, JoinError>>, TaskHandle<T>) {
let (tx, rx) = oneshot::channel::<Result<T, JoinError>>();
let handle = TaskHandle::new(
TaskId::new_for_test(task_slot, 0),
rx,
std::sync::Weak::new(),
);
(tx, handle)
}
#[test]
fn join_to_outcome_maps_value_and_application_error() {
let ok: Outcome<i32, &'static str> = join_to_outcome(Ok(Ok(7)));
assert!(matches!(ok, Outcome::Ok(7)));
let err: Outcome<i32, &'static str> = join_to_outcome(Ok(Err("boom")));
assert!(matches!(err, Outcome::Err("boom")));
}
#[test]
fn join_to_outcome_maps_poll_after_completion_to_panic() {
let outcome: Outcome<i32, &'static str> =
join_to_outcome(Err(JoinError::PolledAfterCompletion));
match outcome {
Outcome::Panicked(payload) => {
assert_eq!(payload.message(), "join handle polled after completion");
}
other => panic!("expected panic outcome, got {other:?}"),
}
}
#[test]
fn join_summary_tracks_completed_and_worst_severity() {
let mut summary = JoinSummary::default();
summary.record(&Outcome::<(), ()>::Ok(()));
summary.record(&Outcome::<(), ()>::Err(()));
summary.record(&Outcome::<(), ()>::Cancelled(crate::CancelReason::user(
"cancelled",
)));
assert_eq!(summary.completed(), 3);
assert_eq!(summary.worst(), Severity::Cancelled);
}
#[test]
fn join_set_id_is_deterministic_from_owning_region() {
let scope =
Scope::<FailFast>::new(crate::RegionId::new_for_test(7, 1), crate::Budget::INFINITE);
let set = JoinSet::<(), (), FailFast>::new(&scope);
assert_eq!(set.set_id, scope.region_id().as_u64());
assert_eq!(set.next_member_index, 0);
}
#[test]
fn join_set_spawn_trace_records_parent_set_fields() {
let (entries, set_id, region) = run_in_runtime(|cx| async move {
let collector = LogCollector::new(8).with_min_level(LogLevel::Trace);
cx.set_log_collector(collector.clone());
let mut set = JoinSet::<u32, (), _>::in_cx(&cx);
let set_id = set.set_id.to_string();
let region = cx.region_id().to_string();
set.spawn(&cx, |_| async move { Ok::<u32, ()>(1) })
.expect("first member spawns");
set.spawn(&cx, |_| async move { Ok::<u32, ()>(2) })
.expect("second member spawns");
let values = set
.join_all(&cx)
.await
.into_iter()
.map(|outcome| outcome.expect("member ok"))
.collect::<Vec<_>>();
assert_eq!(values, vec![1, 2]);
(collector.peek(), set_id, region)
});
let spawn_entries = entries
.iter()
.filter(|entry| entry.message() == "join_set.spawn")
.collect::<Vec<_>>();
assert_eq!(spawn_entries.len(), 2);
for (index, entry) in spawn_entries.iter().enumerate() {
let member_index = index.to_string();
let active_members = (index + 1).to_string();
assert_eq!(entry.get_field("join_set_id"), Some(set_id.as_str()));
assert_eq!(entry.get_field("region"), Some(region.as_str()));
assert_eq!(entry.get_field("spawn_kind"), Some("send"));
assert_eq!(entry.get_field("member_index"), Some(member_index.as_str()));
assert_eq!(
entry.get_field("active_members"),
Some(active_members.as_str())
);
}
}
#[test]
fn join_next_returns_none_for_empty_set() {
let observed = run_in_runtime(|cx| async move {
let mut set = JoinSet::<u32, (), _>::in_cx(&cx);
let next = set.join_next(&cx).await;
let summary = set.summary();
(next, summary.completed(), summary.worst())
});
assert_eq!(observed.0, None);
assert_eq!(observed.1, 0);
assert_eq!(observed.2, Severity::Ok);
}
#[test]
fn try_join_next_returns_none_until_member_is_ready() {
let scope =
Scope::<FailFast>::new(crate::RegionId::new_for_test(8, 1), crate::Budget::INFINITE);
let mut set = JoinSet::<u32, &'static str, FailFast>::new(&scope);
let (_tx, handle) = manual_handle::<Result<u32, &'static str>>(1);
set.handles.push(handle);
assert!(set.try_join_next().is_none());
assert_eq!(set.len(), 1);
assert_eq!(set.summary().completed(), 0);
assert_eq!(set.summary().worst(), Severity::Ok);
}
#[test]
fn try_join_next_collects_earliest_spawned_ready_member() {
let cx = Cx::for_testing();
let scope =
Scope::<FailFast>::new(crate::RegionId::new_for_test(9, 1), crate::Budget::INFINITE);
let mut set = JoinSet::<u32, &'static str, FailFast>::new(&scope);
let (_pending_tx, pending_handle) = manual_handle::<Result<u32, &'static str>>(1);
let (ready_tx, ready_handle) = manual_handle::<Result<u32, &'static str>>(2);
set.handles.push(pending_handle);
set.handles.push(ready_handle);
ready_tx
.send(&cx, Ok(Ok(9)))
.expect("ready member result sends");
let outcome = set.try_join_next().expect("ready member collected");
assert!(matches!(outcome, Outcome::Ok(9)));
assert_eq!(set.len(), 1);
assert_eq!(set.summary().completed(), 1);
assert_eq!(set.summary().worst(), Severity::Ok);
assert!(set.try_join_next().is_none());
}
#[test]
fn try_join_next_maps_application_error() {
let cx = Cx::for_testing();
let scope = Scope::<FailFast>::new(
crate::RegionId::new_for_test(10, 1),
crate::Budget::INFINITE,
);
let mut set = JoinSet::<u32, &'static str, FailFast>::new(&scope);
let (ready_tx, ready_handle) = manual_handle::<Result<u32, &'static str>>(1);
set.handles.push(ready_handle);
ready_tx
.send(&cx, Ok(Err("boom")))
.expect("ready member error sends");
let outcome = set.try_join_next().expect("ready member collected");
assert!(matches!(outcome, Outcome::Err("boom")));
assert!(set.is_empty());
assert_eq!(set.summary().completed(), 1);
assert_eq!(set.summary().worst(), Severity::Err);
}
#[test]
fn join_next_ready_order_is_deterministic_across_replays() {
fn collect_ready_values(seed_values: &[u32]) -> Vec<u32> {
let seed_values = seed_values.to_vec();
run_in_runtime(move |cx| async move {
let scope = Scope::<FailFast>::new(
crate::RegionId::new_for_test(11, 1),
crate::Budget::INFINITE,
);
let mut set = JoinSet::<u32, &'static str, FailFast>::new(&scope);
for (index, value) in seed_values.into_iter().enumerate() {
let (ready_tx, ready_handle) =
manual_handle::<Result<u32, &'static str>>((index + 1) as u32);
set.handles.push(ready_handle);
ready_tx
.send(&cx, Ok(Ok(value)))
.expect("ready member result sends");
}
let mut observed = Vec::new();
while let Some(outcome) = set.join_next(&cx).await {
observed.push(outcome.expect("member ok"));
}
observed
})
}
let seed_values = [5, 3, 8, 1, 13, 2, 21, 1];
assert_eq!(collect_ready_values(&seed_values), seed_values);
assert_eq!(
collect_ready_values(&seed_values),
collect_ready_values(&seed_values)
);
}
#[test]
fn join_all_collects_members_in_spawn_order() {
let (outcomes, next_member_index) = run_in_runtime(|cx| async move {
let mut set = JoinSet::<u32, (), _>::in_cx(&cx);
for value in [30_u32, 10, 20] {
set.spawn(&cx, move |_| async move { Ok::<u32, ()>(value) })
.expect("join-set member spawns");
}
let next_member_index = set.next_member_index;
(set.join_all(&cx).await, next_member_index)
});
assert_eq!(next_member_index, 3);
assert_eq!(
outcomes
.into_iter()
.map(|outcome| outcome.expect("member ok"))
.collect::<Vec<_>>(),
vec![30, 10, 20]
);
}
#[test]
fn join_all_drains_ten_thousand_ready_members_in_spawn_order() {
const MEMBERS: usize = 10_000;
let outcomes = run_in_runtime(|cx| async move {
let scope = Scope::<FailFast>::new(
crate::RegionId::new_for_test(12, 1),
crate::Budget::INFINITE,
);
let mut set = JoinSet::<u64, &'static str, FailFast>::new(&scope);
for index in 0..MEMBERS {
let (ready_tx, ready_handle) =
manual_handle::<Result<u64, &'static str>>((index + 1) as u32);
set.handles.push(ready_handle);
ready_tx
.send(&cx, Ok(Ok(index as u64)))
.expect("ready member result sends");
}
assert_eq!(set.len(), MEMBERS);
set.join_all(&cx).await
});
assert_eq!(outcomes.len(), MEMBERS);
let mut observed_sum = 0_u64;
for (expected, outcome) in outcomes.into_iter().enumerate() {
let value = outcome.expect("member ok");
assert_eq!(value, expected as u64);
observed_sum += value;
}
assert_eq!(observed_sum, (MEMBERS as u64 - 1) * MEMBERS as u64 / 2);
}
#[test]
fn cancel_all_drains_live_members_as_cancelled_outcomes() {
let (outcomes, summary) = run_in_runtime(|cx| async move {
let started = Arc::new(AtomicUsize::new(0));
let mut set = JoinSet::<u32, crate::error::Error, _>::in_cx(&cx);
for _ in 0..3 {
let started = Arc::clone(&started);
set.spawn(&cx, move |member_cx| async move {
started.fetch_add(1, Ordering::SeqCst);
loop {
if let Err(error) = member_cx.checkpoint() {
return Err(error);
}
yield_now().await;
}
})
.expect("join-set member spawns");
}
for _ in 0..16 {
if started.load(Ordering::SeqCst) == 3 {
break;
}
yield_now().await;
}
assert_eq!(
started.load(Ordering::SeqCst),
3,
"all members must start before cancel_all is tested"
);
let outcomes = set.cancel_all(&cx).await;
let mut summary = JoinSummary::default();
for outcome in &outcomes {
summary.record(outcome);
}
(outcomes, summary)
});
assert_eq!(outcomes.len(), 3);
assert!(
outcomes
.iter()
.all(|outcome| matches!(outcome, Outcome::Cancelled(_))),
"cancel_all must drain every live member to a terminal Cancelled outcome: {outcomes:?}"
);
assert_eq!(summary.completed(), 3);
assert_eq!(summary.worst(), Severity::Cancelled);
}
#[test]
fn cancel_all_with_race_loser_reason_satisfies_loser_drain_witness() {
let outcomes = run_in_runtime(|cx| async move {
let started = Arc::new(AtomicUsize::new(0));
let mut set = JoinSet::<u32, crate::error::Error, _>::in_cx(&cx);
for _ in 0..2 {
let started = Arc::clone(&started);
set.spawn(&cx, move |member_cx| async move {
started.fetch_add(1, Ordering::SeqCst);
loop {
if let Err(error) = member_cx.checkpoint() {
return Err(error);
}
yield_now().await;
}
})
.expect("join-set member spawns");
}
for _ in 0..16 {
if started.load(Ordering::SeqCst) == 2 {
break;
}
yield_now().await;
}
assert_eq!(
started.load(Ordering::SeqCst),
2,
"both members must start before race-loser cancellation"
);
set.cancel_all_with_reason(&cx, CancelReason::race_loser())
.await
});
let loser_refs = outcomes.iter().collect::<Vec<_>>();
let witness = crate::combinator::race::verify_losers_drained(&loser_refs)
.expect("JoinSet-drained race losers satisfy L-LOSER-DRAINED");
assert_eq!(witness.losers_checked(), 2);
assert!(
outcomes
.iter()
.all(|outcome| matches!(outcome, Outcome::Cancelled(reason) if reason.is_kind(crate::types::CancelKind::RaceLost))),
"explicit race-loser cancellation must preserve RaceLost attribution: {outcomes:?}"
);
}
}