use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use bevy_ecs::entity::Entity;
use tokio::runtime::Handle;
use tokio::sync::Mutex;
use tokio::sync::Notify;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
pub type ToolExecFuture = Pin<Box<dyn Future<Output = Vec<(String, String)>> + Send>>;
pub type BoxedToolExec = Box<dyn FnOnce() -> ToolExecFuture + Send>;
pub struct ToolJob {
pub entity: Entity,
pub exec: BoxedToolExec,
pub cancel: crate::cancel::CancelToken,
}
pub struct ToolOutcome {
pub entity: Entity,
pub results: Vec<(String, String)>,
pub elapsed: std::time::Duration,
}
pub type SharedJobRx = Arc<Mutex<UnboundedReceiver<ToolJob>>>;
pub async fn tool_worker(
jobs: SharedJobRx,
results: UnboundedSender<ToolOutcome>,
wake: Arc<Notify>,
) {
loop {
let next = {
let mut rx = jobs.lock().await;
rx.recv().await
};
let Some(ToolJob {
entity,
exec,
cancel,
}) = next
else {
return; };
let started = std::time::Instant::now();
let out = tokio::select! {
biased;
_ = cancel.cancelled() => continue,
out = exec() => out,
};
let _ = results.send(ToolOutcome {
entity,
results: out,
elapsed: started.elapsed(),
});
wake.notify_one();
}
}
pub fn spawn_tool_pool(
runtime: &Handle,
jobs: UnboundedReceiver<ToolJob>,
results: UnboundedSender<ToolOutcome>,
wake: Arc<Notify>,
workers: usize,
) -> Vec<JoinHandle<()>> {
let shared: SharedJobRx = Arc::new(Mutex::new(jobs));
(0..workers.max(1))
.map(|_| runtime.spawn(tool_worker(shared.clone(), results.clone(), wake.clone())))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::mpsc;
fn job(entity: u32, pairs: Vec<(&'static str, &'static str)>) -> ToolJob {
job_with(entity, pairs, crate::cancel::CancelToken::new())
}
fn job_with(
entity: u32,
pairs: Vec<(&'static str, &'static str)>,
cancel: crate::cancel::CancelToken,
) -> ToolJob {
ToolJob {
entity: Entity::from_raw_u32(entity).expect("index came from a live entity id"),
exec: Box::new(move || {
Box::pin(async move {
pairs
.into_iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
})
}),
cancel,
}
}
fn held_job(
entity: u32,
started: Arc<Notify>,
release: Arc<Notify>,
cancel: crate::cancel::CancelToken,
) -> ToolJob {
ToolJob {
entity: Entity::from_raw_u32(entity).expect("index came from a live entity id"),
exec: Box::new(move || {
Box::pin(async move {
started.notify_one();
release.notified().await;
vec![("held".to_string(), "done".to_string())]
})
}),
cancel,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_released_batch_completes_normally() {
let (jtx, jrx) = mpsc::unbounded_channel();
let (rtx, mut rrx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
let started = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
jtx.send(held_job(
7,
started.clone(),
release.clone(),
crate::cancel::CancelToken::new(),
))
.unwrap();
drop(jtx);
let handles = spawn_tool_pool(&Handle::current(), jrx, rtx, wake, 1);
tokio::time::timeout(std::time::Duration::from_secs(5), started.notified())
.await
.expect("the batch started");
release.notify_one();
let out = tokio::time::timeout(std::time::Duration::from_secs(5), rrx.recv())
.await
.expect("the batch finished")
.expect("an outcome arrived");
assert_eq!(out.results, vec![("held".to_string(), "done".to_string())]);
for h in handles {
let _ = h.await;
}
}
fn shared(rx: UnboundedReceiver<ToolJob>) -> SharedJobRx {
Arc::new(Mutex::new(rx))
}
#[tokio::test]
async fn worker_processes_jobs_in_order_then_exits_on_close() {
let (jtx, jrx) = mpsc::unbounded_channel();
let (rtx, mut rrx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
jtx.send(job(1, vec![("c1", "r1")])).unwrap();
jtx.send(job(2, vec![("c2", "r2")])).unwrap();
drop(jtx);
tool_worker(shared(jrx), rtx, wake).await;
let first = rrx.try_recv().unwrap();
assert_eq!(
first.entity,
Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id")
);
assert_eq!(first.results, vec![("c1".to_string(), "r1".to_string())]);
let second = rrx.try_recv().unwrap();
assert_eq!(
second.entity,
Entity::from_raw_u32(2).expect("a small literal index is always a valid entity id")
);
assert!(rrx.try_recv().is_err()); }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_cancelled_batch_is_abandoned_and_frees_the_worker() {
let (jtx, jrx) = mpsc::unbounded_channel();
let (rtx, mut rrx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
let cancel = crate::cancel::CancelToken::new();
let started = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
jtx.send(held_job(
1,
started.clone(),
release.clone(),
cancel.clone(),
))
.unwrap();
jtx.send(job(2, vec![("c2", "r2")])).unwrap();
drop(jtx);
let handles = spawn_tool_pool(&Handle::current(), jrx, rtx, wake, 1);
tokio::time::timeout(std::time::Duration::from_secs(5), started.notified())
.await
.expect("the batch started");
cancel.cancel();
let next = tokio::time::timeout(std::time::Duration::from_secs(5), rrx.recv())
.await
.expect("the worker was freed by the cancel")
.expect("an outcome arrived");
assert_eq!(
next.entity,
Entity::from_raw_u32(2).expect("a small literal index is always a valid entity id"),
"the queued batch ran"
);
assert!(
rrx.try_recv().is_err(),
"and the cancelled batch reported no results"
);
for h in handles {
let _ = h.await;
}
}
#[tokio::test]
async fn worker_survives_dropped_results_receiver() {
let (jtx, jrx) = mpsc::unbounded_channel();
let (rtx, rrx) = mpsc::unbounded_channel();
drop(rrx); let wake = Arc::new(Notify::new());
jtx.send(job(9, vec![("c", "r")])).unwrap();
drop(jtx);
tool_worker(shared(jrx), rtx, wake).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn pool_runs_batches_concurrently_and_all_exit_on_close() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
let (jtx, jrx) = mpsc::unbounded_channel();
let (rtx, mut rrx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
let arrived = Arc::new(AtomicUsize::new(0));
let go = Arc::new(Notify::new());
for i in 1..=3u32 {
let arrived = arrived.clone();
let go = go.clone();
jtx.send(ToolJob {
entity: Entity::from_raw_u32(i).expect("index came from a live entity id"),
exec: Box::new(move || {
Box::pin(async move {
if arrived.fetch_add(1, Ordering::SeqCst) + 1 == 3 {
go.notify_waiters();
}
while arrived.load(Ordering::SeqCst) < 3 {
go.notified().await;
}
vec![("c".to_string(), "r".to_string())]
})
}),
cancel: crate::cancel::CancelToken::new(),
})
.unwrap();
}
drop(jtx);
let handles = spawn_tool_pool(&Handle::current(), jrx, rtx, wake, 3);
for _ in 0..3 {
tokio::time::timeout(Duration::from_secs(5), rrx.recv())
.await
.expect("all batches complete concurrently")
.expect("outcome present");
}
for h in handles {
h.await.unwrap(); }
}
#[tokio::test(flavor = "multi_thread")]
async fn spawn_tool_pool_clamps_zero_to_one_worker() {
let (jtx, jrx) = mpsc::unbounded_channel();
let (rtx, mut rrx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
jtx.send(job(7, vec![("c", "r")])).unwrap();
drop(jtx);
let handles = spawn_tool_pool(&Handle::current(), jrx, rtx, wake, 0);
assert_eq!(handles.len(), 1); let out = rrx.recv().await.unwrap();
assert_eq!(
out.entity,
Entity::from_raw_u32(7).expect("a small literal index is always a valid entity id")
);
for h in handles {
h.await.unwrap();
}
}
}