mod common;
use common::{bounded, ordinary_fiber_count};
use cordis_core::lifecycle::{PluginFailureKind, SpawnError};
use cordis_core::{Context, FiberState, InjectSpec, Plugin, PreparedPlugin, Service};
use parking_lot::Mutex;
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
struct ApplyBoom(String);
struct Scripted<F>(F);
impl<F> Plugin for Scripted<F>
where
F: Fn(Context) -> Result<(), ApplyBoom> + Send + Sync + 'static,
{
type Config = ();
type Input = ();
type PrepareError = Infallible;
type ApplyError = ApplyBoom;
fn prepare(&self, _config: ()) -> Result<(), Infallible> {
Ok(())
}
async fn apply(&self, ctx: Context, _prepared: &()) -> Result<(), ApplyBoom> {
(self.0)(ctx)
}
}
#[derive(Default)]
struct Counter {
uses: AtomicU32,
}
impl Service for Counter {
const NAME: &'static str = "counter";
}
type BarrierSlot<T> = Arc<Mutex<Option<T>>>;
#[tokio::test]
async fn initial_apply_error_rolls_back_lifo_and_leaves_no_resident_fiber() {
let ctx = Context::new();
let order: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
let order_probe = order.clone();
let err = ctx
.spawn(PreparedPlugin::from_input(
Scripted(move |ctx: Context| -> Result<(), ApplyBoom> {
ctx.effect_sync({
let order_probe = order_probe.clone();
move || order_probe.lock().push("first")
})
.unwrap();
let _ = ctx
.provide::<Counter>(Arc::new(Counter::default()))
.unwrap();
ctx.effect_sync({
let order_probe = order_probe.clone();
move || order_probe.lock().push("last")
})
.unwrap();
Err(ApplyBoom("apply boom".to_owned()))
}),
(),
))
.await
.expect_err("a failed initial apply refuses the FiberHandle");
let SpawnError::InitialApply(failure) = err else {
panic!("expected InitialApply, got {err:?}");
};
assert_eq!(failure.kind(), PluginFailureKind::ReturnedError);
assert_eq!(failure.diagnostic(), "apply boom");
assert_eq!(*order.lock(), ["last", "first"]);
assert!(
ctx.try_service::<Counter>().is_err(),
"a rolled-back publication is withdrawn"
);
assert_eq!(ordinary_fiber_count(&ctx), 0);
}
#[tokio::test]
async fn initial_apply_panic_rolls_back_and_leaves_no_resident_fiber() {
struct Panicky;
impl Plugin for Panicky {
type Config = ();
type Input = ();
type PrepareError = Infallible;
type ApplyError = ApplyBoom;
fn prepare(&self, _config: ()) -> Result<(), Infallible> {
Ok(())
}
async fn apply(&self, ctx: Context, _prepared: &()) -> Result<(), ApplyBoom> {
ctx.effect_sync(|| -> () { panic!("the rollback cleanup stays silent") })
.unwrap();
panic!("apply exploded");
}
}
let ctx = Context::new();
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcome = ctx.spawn(PreparedPlugin::from_input(Panicky, ())).await;
std::panic::set_hook(default_hook);
let err = outcome.expect_err("a panicking initial apply refuses the FiberHandle");
let SpawnError::InitialApply(failure) = err else {
panic!("expected InitialApply, got {err:?}");
};
assert_eq!(failure.kind(), PluginFailureKind::Panic);
assert_eq!(failure.diagnostic(), "apply exploded");
assert_eq!(ordinary_fiber_count(&ctx), 0);
}
#[test]
fn preparation_panic_is_an_ordinary_unwind_before_admission() {
struct PanickyPrepare;
impl Plugin for PanickyPrepare {
type Config = ();
type Input = ();
type PrepareError = Infallible;
type ApplyError = ApplyBoom;
fn name(&self) -> std::borrow::Cow<'_, str> {
panic!("name exploded")
}
fn prepare(&self, _config: ()) -> Result<(), Infallible> {
panic!("prepare exploded")
}
async fn apply(&self, _ctx: Context, _prepared: &()) -> Result<(), ApplyBoom> {
Ok(())
}
}
let ctx = Context::new();
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let prepare_outcome =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| PanickyPrepare.prepare(())));
let seal_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
PreparedPlugin::from_input(PanickyPrepare, ())
}));
std::panic::set_hook(default_hook);
assert_eq!(
prepare_outcome.unwrap_err().downcast_ref::<&str>(),
Some(&"prepare exploded"),
"prepare's panic unwinds to the caller untouched"
);
assert_eq!(
seal_outcome
.err()
.and_then(|payload| payload.downcast_ref::<&str>().copied()),
Some("name exploded"),
"sealing's declaration materialization unwinds untouched"
);
assert_eq!(
ordinary_fiber_count(&ctx),
0,
"pre-admission unwinds allocate nothing"
);
}
#[tokio::test]
async fn eligible_spawn_delivers_a_live_quiescent_active_fiber_handle() {
let ctx = Context::new();
let applied = Arc::new(AtomicU32::new(0));
let applied_probe = applied.clone();
let fiber_handle = ctx
.spawn(PreparedPlugin::from_input(
Scripted(move |_ctx: Context| -> Result<(), ApplyBoom> {
applied_probe.fetch_add(1, Ordering::SeqCst);
Ok(())
}),
(),
))
.await
.expect("an eligible spawn hands off its FiberHandle");
assert_eq!(fiber_handle.state(), FiberState::Active);
let _identity = fiber_handle.id();
assert_eq!(ordinary_fiber_count(&ctx), 1);
assert_eq!(applied.load(Ordering::SeqCst), 1, "apply ran exactly once");
}
#[tokio::test]
async fn missing_requirements_hand_off_a_stable_pending_fiber_handle_without_applying() {
struct Wants;
impl Plugin for Wants {
type Config = ();
type Input = ();
type PrepareError = Infallible;
type ApplyError = ApplyBoom;
fn inject(&self) -> InjectSpec {
InjectSpec::none().require(Counter::NAME)
}
fn prepare(&self, _config: ()) -> Result<(), Infallible> {
Ok(())
}
async fn apply(&self, ctx: Context, _prepared: &()) -> Result<(), ApplyBoom> {
let counter = ctx
.try_service::<Counter>()
.map_err(|e| ApplyBoom(e.to_string()))?;
counter.uses.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
let ctx = Context::new();
let counter = Arc::new(Counter::default());
let fiber_handle = ctx
.spawn(PreparedPlugin::from_input(Wants, ()))
.await
.expect("a Pending fiber is still a live quiescent handoff");
assert_eq!(fiber_handle.state(), FiberState::Pending);
assert_eq!(fiber_handle.pending_missing(), [Counter::NAME.to_owned()]);
assert_eq!(
counter.uses.load(Ordering::SeqCst),
0,
"apply never ran for the ineligible fiber"
);
assert_eq!(
bounded(200, fiber_handle.ready())
.await
.expect("the Pending FiberHandle is quiescent at handoff")
.unwrap(),
FiberState::Pending
);
let _ = ctx.provide::<Counter>(counter.clone()).unwrap();
assert_eq!(
fiber_handle.ready().await.unwrap(),
FiberState::Active,
"the publication converged the parked fiber"
);
assert_eq!(counter.uses.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_mutation_racing_the_initial_apply_is_converged_before_handoff() {
let ctx = Context::new();
let counter = Arc::new(Counter::default());
let provider = {
let counter = counter.clone();
ctx.spawn(PreparedPlugin::from_input(
Scripted(move |ctx: Context| -> Result<(), ApplyBoom> {
let _ = ctx
.provide::<Counter>(counter.clone())
.map_err(|e| ApplyBoom(e.to_string()))?;
Ok(())
}),
(),
))
.await
.expect("the provider spawns")
};
let entered: BarrierSlot<tokio::sync::oneshot::Sender<()>> = Arc::new(Mutex::new(None));
let go: BarrierSlot<tokio::sync::oneshot::Receiver<()>> = Arc::new(Mutex::new(None));
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (go_tx, go_rx) = tokio::sync::oneshot::channel();
*entered.lock() = Some(entered_tx);
*go.lock() = Some(go_rx);
struct Dependent {
entered: BarrierSlot<tokio::sync::oneshot::Sender<()>>,
go: BarrierSlot<tokio::sync::oneshot::Receiver<()>>,
applies: Arc<AtomicU32>,
drained: Arc<AtomicU32>,
}
impl Plugin for Dependent {
type Config = ();
type Input = ();
type PrepareError = Infallible;
type ApplyError = ApplyBoom;
fn inject(&self) -> InjectSpec {
InjectSpec::none().require(Counter::NAME)
}
fn prepare(&self, _config: ()) -> Result<(), Infallible> {
Ok(())
}
async fn apply(&self, ctx: Context, _prepared: &()) -> Result<(), ApplyBoom> {
self.applies.fetch_add(1, Ordering::SeqCst);
ctx.effect_sync({
let drained = self.drained.clone();
move || {
drained.fetch_add(1, Ordering::SeqCst);
}
})
.map_err(|e| ApplyBoom(e.to_string()))?;
let _ = self
.entered
.lock()
.take()
.expect("the barrier is armed once")
.send(());
let go = self.go.lock().take().expect("the barrier is armed once");
let _ = go.await;
Ok(())
}
}
let applies = Arc::new(AtomicU32::new(0));
let drained = Arc::new(AtomicU32::new(0));
let spawn = tokio::spawn({
let ctx = ctx.clone();
let (applies, drained) = (applies.clone(), drained.clone());
async move {
ctx.spawn(PreparedPlugin::from_input(
Dependent {
entered,
go,
applies,
drained,
},
(),
))
.await
}
});
bounded(2000, entered_rx)
.await
.expect("the dependent's apply is in flight")
.unwrap();
provider.dispose().await.unwrap();
go_tx.send(()).unwrap();
let fiber_handle = bounded(2000, spawn)
.await
.expect("the creation completes")
.unwrap()
.expect("the raced withdrawal converges, not fails");
assert_eq!(fiber_handle.state(), FiberState::Pending);
assert_eq!(fiber_handle.pending_missing(), [Counter::NAME.to_owned()]);
assert_eq!(
applies.load(Ordering::SeqCst),
1,
"the converged Pending target is never re-applied"
);
assert_eq!(
drained.load(Ordering::SeqCst),
1,
"the superseded generation drained before handoff"
);
}
#[tokio::test]
async fn spawn_through_an_inactive_context_is_refused_before_allocation() {
let ctx = Context::new();
let captured: Arc<Mutex<Option<Context>>> = Arc::new(Mutex::new(None));
let captured_probe = captured.clone();
let fiber_handle = ctx
.spawn(PreparedPlugin::from_input(
Scripted(move |ctx: Context| -> Result<(), ApplyBoom> {
*captured_probe.lock() = Some(ctx);
Ok(())
}),
(),
))
.await
.expect("the first spawn hands off");
fiber_handle.dispose().await.unwrap();
let captured_ctx = captured.lock().clone().unwrap();
let err = captured_ctx
.spawn(PreparedPlugin::from_input(
Scripted(|_ctx: Context| -> Result<(), ApplyBoom> { Ok(()) }),
(),
))
.await
.expect_err("a disposed spawning context refuses");
assert!(
matches!(err, SpawnError::InactiveContext),
"the refusal is the admission gate's: {err:?}"
);
assert_eq!(
ordinary_fiber_count(&ctx),
0,
"the refusal allocated nothing"
);
}
#[tokio::test]
async fn caller_cancellation_after_commit_completes_disposal_and_unlink() {
let ctx = Context::new();
let entered: BarrierSlot<tokio::sync::oneshot::Sender<()>> = Arc::new(Mutex::new(None));
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
*entered.lock() = Some(entered_tx);
let rolled_back = Arc::new(AtomicU32::new(0));
struct Blocking {
entered: BarrierSlot<tokio::sync::oneshot::Sender<()>>,
rolled_back: Arc<AtomicU32>,
}
impl Plugin for Blocking {
type Config = ();
type Input = ();
type PrepareError = Infallible;
type ApplyError = ApplyBoom;
fn prepare(&self, _config: ()) -> Result<(), Infallible> {
Ok(())
}
async fn apply(&self, ctx: Context, _prepared: &()) -> Result<(), ApplyBoom> {
ctx.effect_sync({
let rb = self.rolled_back.clone();
move || {
rb.fetch_add(1, Ordering::SeqCst);
}
})
.map_err(|e| ApplyBoom(e.to_string()))?;
let _ = self
.entered
.lock()
.take()
.expect("the barrier is armed once")
.send(());
std::future::pending::<()>().await;
Ok(())
}
}
let spawn = tokio::spawn({
let ctx = ctx.clone();
let rb = rolled_back.clone();
async move {
ctx.spawn(PreparedPlugin::from_input(
Blocking {
entered,
rolled_back: rb,
},
(),
))
.await
}
});
bounded(2000, entered_rx)
.await
.expect("the apply is in flight with the cleanup committed")
.unwrap();
spawn.abort();
assert!(
spawn.await.unwrap_err().is_cancelled(),
"the caller cancelled; nothing is reported to it"
);
bounded(2000, async {
loop {
if rolled_back.load(Ordering::SeqCst) == 1 && ordinary_fiber_count(&ctx) == 0 {
return;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the guard drained the generation and unlinked the fiber");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn caller_cancellation_mid_cleanup_completes_the_claimed_cleanup() {
let ctx = Context::new();
let entered: BarrierSlot<tokio::sync::oneshot::Sender<()>> = Arc::new(Mutex::new(None));
let release: BarrierSlot<tokio::sync::oneshot::Receiver<()>> = Arc::new(Mutex::new(None));
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
*entered.lock() = Some(entered_tx);
*release.lock() = Some(release_rx);
let cleanup_done = Arc::new(AtomicU32::new(0));
let done_probe = cleanup_done.clone();
let plugin = Scripted(move |ctx: Context| -> Result<(), ApplyBoom> {
ctx.effect({
let entered = entered.clone();
let release = release.clone();
let done = done_probe.clone();
move || {
let entered = entered.lock().take().expect("armed once");
let release = release.lock().take().expect("armed once");
async move {
let _ = entered.send(());
let _ = release.await;
done.fetch_add(1, Ordering::SeqCst);
}
}
})
.map_err(|e| ApplyBoom(e.to_string()))?;
Err(ApplyBoom("apply boom".to_owned()))
});
let spawn = tokio::spawn({
let ctx = ctx.clone();
async move { ctx.spawn(PreparedPlugin::from_input(plugin, ())).await }
});
bounded(2000, entered_rx)
.await
.expect("the rollback's cleanup is claimed and in flight")
.unwrap();
spawn.abort();
assert!(spawn.await.unwrap_err().is_cancelled());
release_tx.send(()).unwrap();
bounded(2000, async {
loop {
if cleanup_done.load(Ordering::SeqCst) == 1 && ordinary_fiber_count(&ctx) == 0 {
return;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the claimed cleanup completed and the fiber unlinked");
}
fn poll_once<F: Future>(fut: std::pin::Pin<&mut F>) -> std::task::Poll<F::Output> {
let mut task_cx = std::task::Context::from_waker(std::task::Waker::noop());
fut.poll(&mut task_cx)
}
#[test]
fn off_runtime_cancellation_drives_the_rollback_under_a_runtime() {
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
let release_rx = Arc::new(Mutex::new(Some(release_rx)));
let ctx = Context::new();
let plugin = Scripted(move |ctx: Context| -> Result<(), ApplyBoom> {
let entered_tx = entered_tx.clone();
let done_tx = done_tx.clone();
let release_rx = release_rx.clone();
ctx.effect(move || {
let release_rx = release_rx.lock().take().expect("armed once");
async move {
let _ = entered_tx.send(());
let _ = release_rx.await;
tokio::spawn(async move {}).await.unwrap();
let _ = done_tx.send(());
}
})
.map_err(|e| ApplyBoom(e.to_string()))?;
Err(ApplyBoom("apply boom".to_owned()))
});
let mut spawn = Box::pin(ctx.spawn(PreparedPlugin::from_input(plugin, ())));
assert!(
poll_once(spawn.as_mut()).is_pending(),
"parked inside the rollback's blocking cleanup"
);
entered_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("the cleanup started on the framework's own thread");
drop(spawn);
release_tx.send(()).unwrap();
done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("the Tokio-touching cleanup completed, not panicked");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while ordinary_fiber_count(&ctx) != 0 {
assert!(
std::time::Instant::now() < deadline,
"the fiber was unlinked framework-side"
);
std::thread::yield_now();
}
}