use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use super::cx::Cx;
#[derive(Debug)]
pub enum ScopedCpuError {
Cancelled(crate::error::Error),
ChildPanicked {
child: usize,
message: String,
},
WorkerCapExceeded {
cap: usize,
},
}
impl core::fmt::Display for ScopedCpuError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ScopedCpuError::Cancelled(inner) => {
write!(f, "scoped cpu region cancelled: {inner}")
}
ScopedCpuError::ChildPanicked { child, message } => {
write!(f, "scoped cpu child {child} panicked: {message}")
}
ScopedCpuError::WorkerCapExceeded { cap } => {
write!(f, "scoped cpu worker cap {cap} exceeded")
}
}
}
}
impl std::error::Error for ScopedCpuError {}
pub struct CpuCx<Caps> {
cx: Cx<Caps>,
latch: Arc<AtomicBool>,
child: usize,
}
impl<Caps> CpuCx<Caps> {
#[must_use]
pub fn child(&self) -> usize {
self.child
}
pub fn checkpoint(&self) -> Result<(), crate::error::Error> {
if self.latch.load(Ordering::Acquire) {
return Err(crate::error::Error::new(crate::error::ErrorKind::Cancelled));
}
self.cx.checkpoint()
}
#[must_use]
pub fn is_cancel_requested(&self) -> bool {
self.latch.load(Ordering::Acquire) || self.cx.is_cancel_requested()
}
}
pub struct ScopedCpu<'scope, 'env, Caps> {
scope: &'scope std::thread::Scope<'scope, 'env>,
cx: Cx<Caps>,
latch: Arc<AtomicBool>,
panic_box: Arc<Mutex<Option<(usize, String)>>>,
spawned: Arc<AtomicUsize>,
cap: usize,
}
impl<'scope, Caps: Send + Sync + 'static> ScopedCpu<'scope, '_, Caps> {
pub fn spawn<F>(&self, f: F) -> Result<(), ScopedCpuError>
where
F: FnOnce(&CpuCx<Caps>) + Send + 'scope,
{
let child = self.spawned.fetch_add(1, Ordering::AcqRel);
if child >= self.cap {
self.spawned.fetch_sub(1, Ordering::AcqRel);
return Err(ScopedCpuError::WorkerCapExceeded { cap: self.cap });
}
let cx = self.cx.clone();
let latch = Arc::clone(&self.latch);
let panic_box = Arc::clone(&self.panic_box);
self.scope.spawn(move || {
let child_cx = CpuCx {
cx,
latch: Arc::clone(&latch),
child,
};
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
f(&child_cx);
}));
if let Err(payload) = outcome {
let message = super::scope::payload_to_string(&payload);
let mut slot = panic_box.lock().unwrap_or_else(|poisoned| {
poisoned.into_inner()
});
if slot.is_none() {
*slot = Some((child, message));
}
drop(slot);
latch.store(true, Ordering::Release);
}
});
Ok(())
}
#[must_use]
pub fn spawned(&self) -> usize {
self.spawned.load(Ordering::Acquire).min(self.cap)
}
}
impl<Caps: Send + Sync + 'static> Cx<Caps> {
pub fn scoped_cpu<'env, F, R>(&'env self, worker_cap: usize, f: F) -> Result<R, ScopedCpuError>
where
F: for<'scope> FnOnce(&ScopedCpu<'scope, 'env, Caps>) -> R,
{
self.checkpoint().map_err(ScopedCpuError::Cancelled)?;
let latch = Arc::new(AtomicBool::new(false));
let panic_box: Arc<Mutex<Option<(usize, String)>>> = Arc::new(Mutex::new(None));
let spawned = Arc::new(AtomicUsize::new(0));
let result = std::thread::scope(|scope| {
let surface = ScopedCpu {
scope,
cx: self.clone(),
latch: Arc::clone(&latch),
panic_box: Arc::clone(&panic_box),
spawned: Arc::clone(&spawned),
cap: worker_cap,
};
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&surface))) {
Ok(value) => value,
Err(payload) => {
latch.store(true, Ordering::Release);
std::panic::resume_unwind(payload);
}
}
});
if let Some((child, message)) = Arc::try_unwrap(panic_box)
.map(|m| {
m.into_inner()
.unwrap_or_else(|poisoned| poisoned.into_inner())
})
.unwrap_or_else(|arc| {
arc.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.take()
})
{
return Err(ScopedCpuError::ChildPanicked { child, message });
}
self.checkpoint().map_err(ScopedCpuError::Cancelled)?;
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Budget;
#[test]
fn children_borrow_run_local_state() {
let cx = Cx::for_testing();
let cells: Vec<Mutex<u64>> = (0..4).map(|_| Mutex::new(0)).collect();
let out = cx
.scoped_cpu(4, |scope| {
for (i, cell) in cells.iter().enumerate() {
scope
.spawn(move |child| {
assert_eq!(child.child(), i);
child.checkpoint().expect("healthy scope");
*cell.lock().expect("cell") = (i as u64 + 1) * 10;
})
.expect("under cap");
}
scope.spawned()
})
.expect("scope completes");
assert_eq!(out, 4);
let values: Vec<u64> = cells.iter().map(|c| *c.lock().expect("cell")).collect();
assert_eq!(values, vec![10, 20, 30, 40]);
}
#[test]
fn worker_cap_refuses_structurally() {
let cx = Cx::for_testing();
let refused = cx
.scoped_cpu(2, |scope| {
scope.spawn(|_| {}).expect("first fits");
scope.spawn(|_| {}).expect("second fits");
match scope.spawn(|_| {}) {
Err(ScopedCpuError::WorkerCapExceeded { cap }) => cap,
other => panic!("expected cap refusal, got {other:?}"),
}
})
.expect("scope completes");
assert_eq!(refused, 2);
}
#[test]
fn child_panic_is_contained_and_drains_siblings() {
let cx = Cx::for_testing();
let err = cx
.scoped_cpu(2, |scope| {
scope
.spawn(|child| {
while child.checkpoint().is_ok() {
std::hint::spin_loop();
}
})
.expect("under cap");
scope
.spawn(|_| panic!("deliberate test panic"))
.expect("under cap");
})
.expect_err("panic must surface");
match err {
ScopedCpuError::ChildPanicked { child, message } => {
assert_eq!(child, 1);
assert!(message.contains("deliberate test panic"));
}
other => panic!("expected ChildPanicked, got {other:?}"),
}
cx.checkpoint().expect("cx usable after contained panic");
}
#[test]
fn cancellation_propagates_to_children_and_surfaces() {
let cx = Cx::for_testing();
let err = cx
.scoped_cpu(1, |scope| {
scope
.spawn(|child| {
child.cx.set_cancel_requested(true);
assert!(child.is_cancel_requested());
child
.checkpoint()
.expect_err("checkpoint observes the request");
})
.expect("under cap");
})
.expect_err("exit checkpoint surfaces cancellation");
assert!(matches!(err, ScopedCpuError::Cancelled(_)));
}
#[test]
fn exhausted_budget_refuses_at_entry() {
let cx = Cx::for_testing_with_budget(Budget {
poll_quota: 0,
..Budget::INFINITE
});
let spawned = AtomicUsize::new(0);
let err = cx
.scoped_cpu(4, |scope| {
scope
.spawn(|_| {
spawned.fetch_add(1, Ordering::SeqCst);
})
.ok();
})
.expect_err("zero poll quota refuses at entry");
assert!(matches!(err, ScopedCpuError::Cancelled(_)));
assert_eq!(spawned.load(Ordering::SeqCst), 0, "no work started");
}
#[test]
fn orchestrator_panic_drains_looping_child_and_propagates() {
let cx = Cx::for_testing();
let child_saw_cancel = Arc::new(AtomicBool::new(false));
let child_saw_cancel_for_closure = Arc::clone(&child_saw_cancel);
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = cx.scoped_cpu(2, |scope| {
let flag = Arc::clone(&child_saw_cancel_for_closure);
scope
.spawn(move |child| {
while child.checkpoint().is_ok() {
std::hint::spin_loop();
}
flag.store(true, Ordering::SeqCst);
})
.expect("first child fits under cap");
panic!("orchestrator boom");
});
}));
assert!(
outcome.is_err(),
"orchestrator panic must propagate to the caller"
);
assert!(
child_saw_cancel.load(Ordering::SeqCst),
"looping child must observe the latch and drain (no hang)"
);
}
}