use std::sync::{mpsc, LazyLock};
use crossbeam_channel::{bounded, Receiver as CrossbeamReceiver, Sender as CrossbeamSender};
use rustc_hash::FxHashMap;
use vyre_driver::BackendError;
use crate::thread_pool::{BoundedWorkerJob, BoundedWorkerPool};
enum TokioBlockingCompletion {
Returned(Result<(), BackendError>),
Panicked(String),
}
enum InFlight {
Pool {
completion: CrossbeamReceiver<Result<(), BackendError>>,
},
TokioBlocking {
completion: mpsc::Receiver<TokioBlockingCompletion>,
task: tokio::task::JoinHandle<()>,
},
}
struct AsyncCopyJob {
copy: Box<dyn FnOnce() -> Result<(), BackendError> + Send + 'static>,
response: CrossbeamSender<Result<(), BackendError>>,
}
impl BoundedWorkerJob for AsyncCopyJob {
type Output = ();
fn response(&self) -> &CrossbeamSender<Result<Self::Output, BackendError>> {
&self.response
}
fn run(self) -> Result<Self::Output, BackendError> {
(self.copy)()
}
}
struct AsyncCopyPool {
pool: BoundedWorkerPool<AsyncCopyJob>,
}
impl AsyncCopyPool {
fn global() -> Result<&'static Self, BackendError> {
static POOL: LazyLock<Result<AsyncCopyPool, BackendError>> =
LazyLock::new(AsyncCopyPool::new);
POOL.as_ref()
.map_err(|error| BackendError::new(error.to_string()))
}
fn new() -> Result<Self, BackendError> {
Ok(Self {
pool: BoundedWorkerPool::new(
256,
"vyre-wgpu-async-copy",
"inspect async copy staging buffer ownership and copy closure invariants.",
"reduce process thread count or increase system nproc limit.",
)?,
})
}
fn submit<F>(
&self,
copy: F,
) -> Result<CrossbeamReceiver<Result<(), BackendError>>, BackendError>
where
F: FnOnce() -> Result<(), BackendError> + Send + 'static,
{
let (response, completion) = bounded(1);
self.pool.submit_blocking(
AsyncCopyJob {
copy: Box::new(copy),
response,
},
"recreate the process; the async-copy worker pool only closes during shutdown.",
)?;
Ok(completion)
}
}
#[derive(Default)]
pub struct AsyncCopyStreams {
in_flight: FxHashMap<String, InFlight>,
}
impl AsyncCopyStreams {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn async_load<F>(&mut self, tag: impl Into<String>, copy: F) -> Result<(), BackendError>
where
F: FnOnce() -> Result<(), BackendError> + Send + 'static,
{
let tag = tag.into();
if self.in_flight.contains_key(&tag) {
return Err(BackendError::new(format!(
"async copy tag `{tag}` is already in flight. Fix: wait before reusing a stream tag."
)));
}
let handle = match tokio::runtime::Handle::try_current() {
Ok(rt) => {
let (completion_tx, completion_rx) = mpsc::sync_channel(1);
let task = rt.spawn_blocking(move || {
let completion =
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(copy)) {
Ok(result) => TokioBlockingCompletion::Returned(result),
Err(payload) => {
TokioBlockingCompletion::Panicked(panic_payload(&*payload).into())
}
};
if completion_tx.send(completion).is_err() {
tracing::warn!(
"Fix: async-copy completion receiver was dropped before the blocking copy reported completion."
);
}
});
InFlight::TokioBlocking {
completion: completion_rx,
task,
}
}
Err(_) => InFlight::Pool {
completion: AsyncCopyPool::global()?.submit(copy)?,
},
};
self.in_flight.insert(tag, handle);
Ok(())
}
pub fn async_wait(&mut self, tag: &str) -> Result<(), BackendError> {
let handle = self.in_flight.remove(tag).ok_or_else(|| {
BackendError::new(format!(
"async copy tag `{tag}` has no matching AsyncLoad. Fix: emit AsyncLoad before AsyncWait."
))
})?;
match handle {
InFlight::Pool { completion } => completion.recv().map_err(|error| {
BackendError::new(format!(
"async copy worker for `{tag}` exited without publishing completion: {error}. Fix: inspect staging buffer ownership and copy closure invariants."
))
})?,
InFlight::TokioBlocking { completion, task } => {
let completion = completion.recv().map_err(|_| {
BackendError::new(format!(
"async copy worker for `{tag}` exited without publishing completion. Fix: inspect staging buffer ownership and copy closure invariants."
))
})?;
drop(task);
match completion {
TokioBlockingCompletion::Returned(result) => result,
TokioBlockingCompletion::Panicked(payload) => Err(BackendError::new(
format!(
"async copy worker for `{tag}` panicked: {payload}. Fix: inspect staging buffer ownership and copy closure invariants."
),
)),
}
}
}
}
pub fn overlap_copy_compute<C, G>(
&mut self,
tag: impl Into<String>,
copy: C,
compute: G,
) -> Result<(), BackendError>
where
C: FnOnce() -> Result<(), BackendError> + Send + 'static,
G: FnOnce() -> Result<(), BackendError>,
{
let tag = tag.into();
self.async_load(tag.clone(), copy)?;
compute()?;
self.async_wait(&tag)
}
}
impl Drop for AsyncCopyStreams {
fn drop(&mut self) {
for (_, handle) in self.in_flight.drain() {
match handle {
InFlight::Pool { .. } => {}
InFlight::TokioBlocking { task, .. } => {
task.abort();
}
}
}
}
}
fn panic_payload<'a>(payload: &'a (dyn std::any::Any + Send + 'static)) -> &'a str {
payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("<non-string panic payload>")
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn async_copy_compute_overlap_is_synchronized_without_sleep() {
let (copy_started_tx, copy_started_rx) = mpsc::sync_channel(1);
let (release_copy_tx, release_copy_rx) = mpsc::sync_channel(1);
let mut streams = AsyncCopyStreams::new();
streams
.overlap_copy_compute(
"stage-0",
move || {
copy_started_tx
.send(())
.expect("Fix: compute side must stay alive until copy starts");
release_copy_rx
.recv()
.expect("Fix: compute side must release copy before wait");
Ok(())
},
move || {
copy_started_rx
.recv_timeout(Duration::from_secs(5))
.expect("Fix: copy work must start before compute can release it");
release_copy_tx
.send(())
.expect("Fix: copy side must stay alive until compute releases it");
Ok(())
},
)
.expect("Fix: async copy and compute should complete");
}
#[test]
fn tokio_blocking_wait_does_not_need_live_runtime() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("Fix: tokio runtime must build for async copy test");
let mut streams = AsyncCopyStreams::new();
{
let _guard = runtime.enter();
streams
.async_load("stage-0", || Ok(()))
.expect("Fix: async load must enqueue on active tokio runtime");
}
drop(runtime);
streams
.async_wait("stage-0")
.expect("Fix: async wait must join through completion channel without a live runtime");
}
}