use std::any::Any;
use std::cell::Cell;
use std::collections::BTreeMap;
use std::ffi::c_void;
use std::fmt;
use std::io::Write;
use std::mem;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::ptr::NonNull;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use oxijolt_sys::*;
use crate::owned::Owned;
use crate::WorldError;
pub trait JobSystem: Send + Sync + 'static {
fn max_concurrency(&self) -> u32;
fn queue_job(&self, job: Job);
}
pub struct Job {
id: u64,
native: Arc<CallbackJobSystem>,
}
impl Job {
pub fn run(self) {
drop(self);
}
}
impl Drop for Job {
fn drop(&mut self) {
if in_queue_callback() {
return;
}
if let Some(task) = self.native.take_queued(self.id) {
unsafe { task.run() }
}
}
}
#[derive(Clone, Copy)]
struct JobTask {
function: unsafe extern "C" fn(*mut c_void),
arg: NonNull<c_void>,
}
impl JobTask {
unsafe fn run(self) {
unsafe { (self.function)(self.arg.as_ptr()) }
}
}
thread_local! {
static QUEUE_CALLBACK_DEPTH: Cell<u32> = const { Cell::new(0) };
}
fn in_queue_callback() -> bool {
QUEUE_CALLBACK_DEPTH
.try_with(|depth| depth.get() > 0)
.unwrap_or(false)
}
struct InQueueCallback;
impl InQueueCallback {
fn enter() -> Self {
QUEUE_CALLBACK_DEPTH.with(|depth| depth.set(depth.get() + 1));
Self
}
}
impl Drop for InQueueCallback {
fn drop(&mut self) {
QUEUE_CALLBACK_DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
impl fmt::Debug for Job {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Job")
}
}
pub(crate) struct CallbackJobSystem {
queued: Mutex<QueuedJobs>,
native: Owned<JPH_JobSystem>,
}
#[derive(Default)]
struct QueuedJobs {
next_id: u64,
tasks: BTreeMap<u64, JobTask>,
}
impl CallbackJobSystem {
fn add_queued(&self, task: JobTask) -> u64 {
let mut queued = self.queued.lock().unwrap_or_else(PoisonError::into_inner);
let id = queued.next_id;
queued.next_id = id.wrapping_add(1);
queued.tasks.insert(id, task);
id
}
fn take_queued(&self, id: u64) -> Option<JobTask> {
let mut queued = self.queued.lock().unwrap_or_else(PoisonError::into_inner);
queued.tasks.remove(&id)
}
fn release_queued(&self) {
let tasks = {
let mut queued = self.queued.lock().unwrap_or_else(PoisonError::into_inner);
mem::take(&mut queued.tasks)
};
for task in tasks.into_values() {
unsafe { task.run() };
}
}
}
impl Drop for CallbackJobSystem {
fn drop(&mut self) {
self.release_queued();
}
}
unsafe impl Send for CallbackJobSystem {}
unsafe impl Sync for CallbackJobSystem {}
pub(crate) struct QueueContext {
job_system: Arc<dyn JobSystem>,
native: OnceLock<Arc<CallbackJobSystem>>,
panicked: AtomicBool,
panic: Mutex<Option<Box<dyn Any + Send>>>,
}
impl QueueContext {
fn new(job_system: Arc<dyn JobSystem>) -> Self {
Self {
job_system,
native: OnceLock::new(),
panicked: AtomicBool::new(false),
panic: Mutex::new(None),
}
}
pub(crate) fn as_ptr(&self) -> *mut JPH_JobSystem {
let native = self
.native
.get()
.expect("caller job system is created with its context");
native.native.as_ptr()
}
fn job(&self, function: JPH_JobFunction, arg: *mut c_void) -> Job {
match (function, NonNull::new(arg), self.native.get()) {
(Some(function), Some(arg), Some(native)) => Job {
id: native.add_queued(JobTask { function, arg }),
native: Arc::clone(native),
},
_ => abort_in_callback("joltc queued a job without a function or argument"),
}
}
fn queue(&self, job: Job) {
if self.panicked.load(Ordering::Acquire) {
drop(job);
return;
}
let queued = catch_unwind(AssertUnwindSafe(|| self.job_system.queue_job(job)));
if let Err(payload) = queued {
self.record_panic(payload);
}
}
fn record_panic(&self, payload: Box<dyn Any + Send>) {
let first = self
.panicked
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok();
if first {
*self.panic.lock().unwrap_or_else(PoisonError::into_inner) = Some(payload);
} else if let Err(panic_in_drop) = catch_unwind(AssertUnwindSafe(|| drop(payload))) {
mem::forget(panic_in_drop);
}
}
pub(crate) fn finish_update(&self) -> Option<Box<dyn Any + Send>> {
if let Some(native) = self.native.get() {
native.release_queued();
}
let payload = self
.panic
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
self.panicked.store(false, Ordering::Release);
payload
}
}
fn abort_in_callback(what: &str) -> ! {
let _ = writeln!(std::io::stderr(), "oxijolt: {what}; aborting");
std::process::abort()
}
unsafe extern "C" fn queue_job(context: *mut c_void, function: JPH_JobFunction, arg: *mut c_void) {
let context = unsafe { &*context.cast::<QueueContext>() };
let _in_callback = InQueueCallback::enter();
context.queue(context.job(function, arg));
}
unsafe extern "C" fn queue_jobs(
context: *mut c_void,
function: JPH_JobFunction,
args: *mut *mut c_void,
count: u32,
) {
if count == 0 {
return;
}
if args.is_null() {
abort_in_callback("joltc queued a batch without jobs");
}
let context = unsafe { &*context.cast::<QueueContext>() };
let _in_callback = InQueueCallback::enter();
for i in 0..count as usize {
let arg = unsafe { *args.add(i) };
context.queue(context.job(function, arg));
}
}
pub(crate) fn create_caller_job_system(
job_system: Arc<dyn JobSystem>,
max_concurrency: u32,
) -> Result<Arc<QueueContext>, WorldError> {
let context = Arc::new(QueueContext::new(job_system));
let config = JPH_JobSystemConfig {
context: Arc::as_ptr(&context).cast_mut().cast(),
queueJob: Some(queue_job),
queueJobs: Some(queue_jobs),
maxConcurrency: max_concurrency,
maxBarriers: 0,
};
let native = unsafe { Owned::from_raw(JPH_JobSystemCallback_Create(&config)) }
.ok_or(WorldError::AllocationFailed("job system"))?;
let _ = context.native.set(Arc::new(CallbackJobSystem {
queued: Mutex::default(),
native,
}));
Ok(context)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use super::*;
use crate::world::ensure_initialized;
struct AlwaysPanics {
calls: AtomicUsize,
}
impl JobSystem for AlwaysPanics {
fn max_concurrency(&self) -> u32 {
1
}
fn queue_job(&self, job: Job) {
let call = self.calls.fetch_add(1, Ordering::Relaxed) + 1;
drop(job);
panic!("queue_job call {call}");
}
}
unsafe extern "C" fn count_run(arg: *mut c_void) {
unsafe { &*arg.cast::<AtomicUsize>() }.fetch_add(1, Ordering::Relaxed);
}
fn context(job_system: Arc<dyn JobSystem>) -> Arc<QueueContext> {
assert!(ensure_initialized());
create_caller_job_system(job_system, 1).unwrap()
}
fn counting_job(context: &QueueContext, runs: &AtomicUsize) -> Job {
let native = context.native.get().unwrap();
let task = JobTask {
function: count_run,
arg: NonNull::from(runs).cast(),
};
Job {
id: native.add_queued(task),
native: Arc::clone(native),
}
}
fn message(payload: &(dyn Any + Send)) -> String {
payload
.downcast_ref::<String>()
.cloned()
.unwrap_or_default()
}
fn count(runs: &AtomicUsize) -> usize {
runs.load(Ordering::Relaxed)
}
#[test]
fn run_and_drop_each_run_the_job_once() {
let context = context(Arc::new(AlwaysPanics {
calls: AtomicUsize::new(0),
}));
let runs = AtomicUsize::new(0);
counting_job(&context, &runs).run();
assert_eq!(count(&runs), 1);
drop(counting_job(&context, &runs));
assert_eq!(count(&runs), 2);
assert!(context.finish_update().is_none());
assert_eq!(count(&runs), 2);
}
#[test]
fn jobs_run_inside_a_queue_callback_wait_for_the_end_of_the_update() {
let context = context(Arc::new(AlwaysPanics {
calls: AtomicUsize::new(0),
}));
let runs = AtomicUsize::new(0);
{
let _in_callback = InQueueCallback::enter();
counting_job(&context, &runs).run();
drop(counting_job(&context, &runs));
assert_eq!(count(&runs), 0);
}
assert!(context.finish_update().is_none());
assert_eq!(count(&runs), 2);
}
#[test]
fn a_job_released_by_its_update_does_nothing_when_run_later() {
let context = context(Arc::new(AlwaysPanics {
calls: AtomicUsize::new(0),
}));
let runs = AtomicUsize::new(0);
let late = counting_job(&context, &runs);
assert!(context.finish_update().is_none());
assert_eq!(count(&runs), 1);
late.run();
assert_eq!(count(&runs), 1);
}
#[test]
fn the_first_panic_is_kept_later_jobs_skip_the_job_system_and_finish_update_resets() {
let job_system = Arc::new(AlwaysPanics {
calls: AtomicUsize::new(0),
});
let context = context(job_system.clone());
let runs = AtomicUsize::new(0);
let calls = || job_system.calls.load(Ordering::Relaxed);
context.queue(counting_job(&context, &runs));
context.queue(counting_job(&context, &runs));
assert_eq!(count(&runs), 2);
assert_eq!(calls(), 1);
context.record_panic(Box::new(String::from("second")));
let payload = context.finish_update().unwrap();
assert_eq!(message(&*payload), "queue_job call 1");
assert!(context.finish_update().is_none());
context.queue(counting_job(&context, &runs));
assert_eq!(count(&runs), 3);
assert_eq!(calls(), 2);
let payload = context.finish_update().unwrap();
assert_eq!(message(&*payload), "queue_job call 2");
}
#[test]
fn a_discarded_payload_whose_drop_panics_does_not_unwind() {
struct PanicsOnDrop;
impl Drop for PanicsOnDrop {
fn drop(&mut self) {
panic!("payload drop");
}
}
let context = context(Arc::new(AlwaysPanics {
calls: AtomicUsize::new(0),
}));
context.record_panic(Box::new(String::from("first")));
context.record_panic(Box::new(PanicsOnDrop));
assert_eq!(message(&*context.finish_update().unwrap()), "first");
}
#[test]
fn concurrent_panics_keep_one_payload_and_drop_the_others() {
const THREADS: usize = 8;
struct Payload;
static DROPS: AtomicUsize = AtomicUsize::new(0);
impl Drop for Payload {
fn drop(&mut self) {
DROPS.fetch_add(1, Ordering::Relaxed);
}
}
let context = context(Arc::new(AlwaysPanics {
calls: AtomicUsize::new(0),
}));
let start = std::sync::Barrier::new(THREADS);
std::thread::scope(|scope| {
for _ in 0..THREADS {
scope.spawn(|| {
start.wait();
context.record_panic(Box::new(Payload));
});
}
});
assert_eq!(DROPS.load(Ordering::Relaxed), THREADS - 1);
let kept = context.finish_update().unwrap();
assert!(kept.is::<Payload>());
drop(kept);
assert_eq!(DROPS.load(Ordering::Relaxed), THREADS);
assert!(context.finish_update().is_none());
}
#[test]
fn a_job_run_while_its_update_finishes_runs_once() {
const JOBS: usize = 64;
let context = context(Arc::new(AlwaysPanics {
calls: AtomicUsize::new(0),
}));
for _ in 0..100 {
let runs = AtomicUsize::new(0);
let jobs: Vec<Job> = (0..JOBS).map(|_| counting_job(&context, &runs)).collect();
let start = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
scope.spawn(|| {
start.wait();
for job in jobs {
job.run();
}
});
start.wait();
assert!(context.finish_update().is_none());
});
assert_eq!(count(&runs), JOBS);
}
}
}