use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use super::cx::Cx;
const ADMISSION_CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(2);
pub(crate) struct ScopedCpuAdmission {
limit: AtomicUsize,
in_use: Mutex<usize>,
available: Condvar,
}
impl ScopedCpuAdmission {
#[must_use]
pub(crate) fn new(limit: usize) -> Self {
Self {
limit: AtomicUsize::new(limit.max(1)),
in_use: Mutex::new(0),
available: Condvar::new(),
}
}
pub(crate) fn set_limit(&self, limit: usize) {
self.limit.store(limit.max(1), Ordering::Release);
self.available.notify_all();
}
fn acquire<Caps>(
self: &Arc<Self>,
cx: &Cx<Caps>,
) -> Result<ScopedCpuPermit, crate::error::Error> {
loop {
cx.checkpoint()?;
let mut in_use = self
.in_use
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if *in_use < self.limit.load(Ordering::Acquire) {
*in_use += 1;
return Ok(ScopedCpuPermit {
admission: Arc::clone(self),
});
}
let (guard, _) = self
.available
.wait_timeout(in_use, ADMISSION_CANCEL_POLL_INTERVAL)
.unwrap_or_else(std::sync::PoisonError::into_inner);
drop(guard);
}
}
}
struct ScopedCpuPermit {
admission: Arc<ScopedCpuAdmission>,
}
impl Drop for ScopedCpuPermit {
fn drop(&mut self) {
let mut in_use = self
.admission
.in_use
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
debug_assert!(*in_use > 0, "scoped CPU admission permit underflow");
*in_use = in_use.saturating_sub(1);
drop(in_use);
self.admission.available.notify_one();
}
}
#[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,
admission: Arc<ScopedCpuAdmission>,
}
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,
{
if self.latch.load(Ordering::Acquire) {
return Err(ScopedCpuError::Cancelled(crate::error::Error::new(
crate::error::ErrorKind::Cancelled,
)));
}
self.cx.checkpoint().map_err(ScopedCpuError::Cancelled)?;
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 permit = match self.admission.acquire(&self.cx) {
Ok(permit) => permit,
Err(error) => {
self.spawned.fetch_sub(1, Ordering::AcqRel);
return Err(ScopedCpuError::Cancelled(error));
}
};
if self.latch.load(Ordering::Acquire) {
self.spawned.fetch_sub(1, Ordering::AcqRel);
drop(permit);
return Err(ScopedCpuError::Cancelled(crate::error::Error::new(
crate::error::ErrorKind::Cancelled,
)));
}
if let Err(error) = self.cx.checkpoint() {
self.spawned.fetch_sub(1, Ordering::AcqRel);
drop(permit);
return Err(ScopedCpuError::Cancelled(error));
}
let cx = self.cx.clone();
let latch = Arc::clone(&self.latch);
let panic_box = Arc::clone(&self.panic_box);
self.scope.spawn(move || {
let _permit = permit;
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 admission = self.spawn_gateway_handle().map_or_else(
|| Arc::new(ScopedCpuAdmission::new(worker_cap)),
|gateway| gateway.scoped_cpu_admission(),
);
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,
admission,
};
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)"
);
}
#[test]
fn post_latch_spawn_is_refused_without_starting_worker_ne8jdw() {
let cx = Cx::for_testing();
let late_worker_ran = AtomicBool::new(false);
let err = cx
.scoped_cpu(2, |scope| {
scope
.spawn(|_| panic!("raise scope latch"))
.expect("first child fits under cap");
while !scope.latch.load(Ordering::Acquire) {
std::thread::yield_now();
}
let late_spawn = scope.spawn(|_| {
late_worker_ran.store(true, Ordering::SeqCst);
});
assert!(matches!(late_spawn, Err(ScopedCpuError::Cancelled(_))));
assert_eq!(scope.spawned(), 1, "refused worker is not counted");
})
.expect_err("first child panic must surface after drain");
assert!(matches!(
err,
ScopedCpuError::ChildPanicked { child: 0, .. }
));
assert!(
!late_worker_ran.load(Ordering::SeqCst),
"post-latch worker body must never start"
);
}
#[test]
fn cancelled_scope_refuses_new_worker_ne8jdw() {
let cx = Cx::for_testing();
let worker_ran = AtomicBool::new(false);
let err = cx
.scoped_cpu(1, |scope| {
cx.set_cancel_requested(true);
let spawn = scope.spawn(|_| {
worker_ran.store(true, Ordering::SeqCst);
});
assert!(matches!(spawn, Err(ScopedCpuError::Cancelled(_))));
assert_eq!(scope.spawned(), 0, "refused worker is not counted");
})
.expect_err("exit checkpoint must retain cancellation");
assert!(matches!(err, ScopedCpuError::Cancelled(_)));
assert!(
!worker_ran.load(Ordering::SeqCst),
"cancelled scope must not start newly submitted work"
);
}
}