Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Common Pitfalls

A reference list of mistakes we have seen (and made) when building simulations with Moonpool.

Delayed Operations Need the Scheduler

Storage read, write, sync, and set-length operations wait for targeted completion events. Network bind, connect, and accept are delayed operations too. If a low-level test polls one of these futures without stepping SimWorld, the future cannot complete.

Fix: Prefer SimulationBuilder::run(), which already interleaves executor drains and scheduler steps. For a low-level provider test, use Executor::block_on(...) and a poll_fn driver that steps SimWorld while the future is pending. The complete helper is in Storage Faults.

Confusing Task Yield With Driver Drain

Spawned tasks do not run until the current task yields or parks. If application code spawns a task and immediately checks shared state, that task may not have run yet. Use task_provider.yield_now().await when the application genuinely needs to yield.

The simulation driver has a different job. After sim.step() wakes tasks, it uses executor::until_stalled().await to drain every runnable task before the next event. until_stalled() is driver-only. Awaiting it from a spawned task is an error because that task would be waiting for a drain that includes itself.

Using unwrap()

Moonpool follows a strict no-unwrap() policy. In simulation, a panic from unwrap() is not a clean error report. It is an uncontrolled crash that may mask the real failure and confuse the assertion system.

Fix: Use Result<T, E> with ? everywhere. Map errors with context when needed. For RwLock poison, use .expect("RwLock poisoned: prior task panicked") since poisoning means a prior panic already happened.

Direct Tokio Calls

Calling tokio::time::sleep(), tokio::time::timeout(), or tokio::spawn() bypasses the simulation’s control of time and task scheduling. Your code will use real wall-clock time instead of simulated time, and the simulation cannot inject faults.

Fix: Use provider traits: time.sleep(), time.timeout(), task_provider.spawn_task().

Wrong Runtime Flavor

Moonpool runs on a single OS thread for determinism. Wrapping a simulation in #[tokio::main] or building a tokio runtime around it hands scheduling to tokio: at best redundant, at worst (multi-thread) real parallelism that destroys reproducibility, even though traits are Send-bounded.

Fix: Drive the simulation with the moonpool executor. SimulationBuilder::run() does this for you, and standalone code uses Executor::new(seed) plus executor.block_on(...) (see The Deterministic Executor). Do not use LocalSet, spawn_local, or build_local(): they are gone from the model.

Using ?Send on Dyn Traits

Moonpool’s dyn-stored traits (Process, Workload, FaultInjector) carry Send + Sync + 'static supertraits so customer code can share state through Arc<RwLock<…>> and DashMap. Writing #[async_trait(?Send)] on your impl removes the Send bound the trait promises and the compiler rejects it.

Fix: Use plain #[async_trait] (no ?Send) on dyn-stored impls. Provider traits use native AFIT with -> impl Future<…> + Send and need no attribute at all.

Holding a Lock Across .await

Because spawned futures must be Send, any local you hold across an .await point must also be Send. A std::sync::MutexGuard is !Send, so holding one across an await fails to compile with a confusing “future is not Send” error pointing at the spawn site, not the lock.

Fix: Scope the guard tightly. Lock, read or mutate, drop the guard, then .await. Prefer parking_lot::Mutex or tokio::sync::Mutex when you genuinely need to hold state across awaits — and even then, drop the guard before any long-running await.

#![allow(unused)]
fn main() {
// Bad: guard lives across the await
let guard = state.lock().expect("RwLock poisoned: prior task panicked");
network.send(&guard.payload).await?;

// Good: drop the guard first
let payload = {
    let guard = state.lock().expect("RwLock poisoned: prior task panicked");
    guard.payload.clone()
};
network.send(&payload).await?;
}

Smuggling !Send Types Into Spawned Futures

Rc, RefCell, and raw pointers are !Send. Capturing them in a future that you hand to tokio::spawn or task_provider.spawn_task() poisons the whole future and the compiler refuses it.

Fix: Use Arc<RwLock<…>> or Arc<AtomicX> for shared mutable state. If you have a legitimate single-task data structure, keep it on the stack of a non-spawned future, never close over it in a spawn.

Moving Resource State Back Into world.rs

SimWorld coordinates a global Scheduler<Event> and lifecycle. Network state, faults, operations, and wakers belong to NetworkSimulation. The corresponding storage data belongs to StorageEngine. Reaching through SimInner to mutate a resource and then scheduling directly couples ownership again and usually creates difficult borrows.

Fix: Add a targeted component transition. Let it return ordered schedule and cancel effects, fault records, and a WakeBatch. The world applies those effects through the scheduler and invokes the wakers after releasing its lock. Never wake while holding the world lock because a waker may immediately poll and re-enter the same component.

Unordered Collection Non-Determinism

std::collections::HashMap does not guarantee iteration order, and the order can vary between runs. If your workload iterates a HashMap and the iteration order affects behavior (choosing which account to process, which message to send), you have introduced non-determinism.

Fix: Use BTreeMap / BTreeSet, or collect dependency-owned unordered data into a Vec and sort before iterating. Moonpool’s own workspace denies HashMap and HashSet through Clippy so this class of replay leak fails CI.

Forgetting to Emit Events for Invariants

Invariants read plain tracing events captured into the timeline. If your workload changes state but forgets to emit a corresponding event, invariants see an incomplete history and either miss bugs or report false violations.

Fix: Emit a trace event for every state change you want invariants to observe. The same event also flows to production observability subscribers, so this serves a dual purpose.

#![allow(unused)]
fn main() {
self.model.record_commit(slot, value);
tracing::info!(slot, value, "commit");
}