The Sim Book
A guide to building and testing distributed systems with moonpool, a deterministic simulation framework for Rust.
Moonpool takes ideas from FoundationDB, TigerBeetle and Antithesis and puts them in one set of Rust crates. You write your system once against provider traits. In production the traits are backed by TokioProviders: real TCP, a real disk, a real clock. In tests the same code runs inside a simulator that is deliberately worse than production: partitions, torn writes, slow machines, reboots, all driven by one seed. No #[cfg(test)]. No mocks. No Sim* copies of your types.
The book has six parts and an appendix:
- Part I explains why simulation testing exists and what moonpool offers. You can read it without any code.
- Part II covers the foundations: determinism, the provider pattern, and the split between the system under test and the test driver.
- Part III builds a simulation step by step: processes, workloads, the
SimulationBuilder, chaos, assertions, events, metrics and debugging. - Part IV wires existing applications (axum, tonic, hyper) into the simulator and takes the same code to production and to the browser.
- Part V covers the raw network layer and the typed RPC crate built on it.
- Part VI covers multiverse exploration and the crash-aware journal built on top of the simulator.
- The Appendix holds the reference tables: assertions, crates, configuration, faults, the calibration tool and the
xtaskrunner.
If you are new, start with The Case for Simulation. If you already know why you want simulation, jump to Why Moonpool Exists for the feature tour, then to Your First Simulation. AI agents working on a moonpool codebase should read the Guide for AI Agents.
Index
- Quick Start Routes
- Foreword
- Part I: Why Simulation Testing
- Part II: Foundations
- Part III: Building Simulations
- Part IV: Simulating Existing Applications
- Part V: Raw Network Simulation
- Part VI: Building on Top
- Appendix
This page is a map of the book. Each entry links to one chapter and says what it covers.
Quick Start Routes
| Question | Start here |
|---|---|
| I am a coding agent and I must add a simulation harness | Guide for AI Agents |
| What is moonpool? | The Case for Simulation, then Why Moonpool Exists |
| What is new in moonpool? | State of Moonpool |
| How do I write my first simulation? | Your First Simulation |
| How can one code base run in production and in simulation? | Same Code in Production and Simulation |
| How do providers work? | The Provider Pattern |
| How do I add faults? | Chaos in Moonpool |
| How does my code mark a place where a fault is interesting? | hint!: Naming Fault Sites and Buggify |
| How do I get a different fault mix on each seed? | Swarm Testing: Masks and Budgets |
| How do I simulate slow machines and gray failures? | CPU Time and Gray Failures |
| How do I take down a whole rack? | Correlated Outages and Self-Crashes |
| How do I use assertions? | Assertions: Finding Bugs |
| A seed failed. What now? | Debugging a Failing Seed |
| How do I test an existing app (axum, tonic, hyper)? | Using moonpool-sim Standalone |
| How do I ship this to production? | Using Providers in Production |
| How do processes call each other? | Typed RPC with moonpool-rpc |
| I need a durable log | A Crash-Aware Journal |
| How does multiverse exploration work? | Multiverse Exploration |
| Which knobs exist and what are their defaults? | Configuration Reference and Fault Reference |
Foreword
- State of Moonpool: the crates, what is stable, what is new, what is missing.
Part I: Why Simulation Testing
- The Case for Simulation: the gap between localhost and production, and why coverage numbers lie.
- Prevention vs Discovery: regression tests guard what we know; simulation finds what we do not know.
- From Mocks to Simulation: why mocks and
#[cfg(test)]fail at scale, and how traits replace them. - A Brief History: FoundationDB, Antithesis, TigerBeetle and what moonpool takes from each.
- Why Moonpool Exists: one short paragraph for each moonpool feature, with links.
Part II: Foundations
- Determinism as a Foundation: threads, I/O and randomness, and how moonpool removes each one.
- The Single-Core Constraint: one thread gives one legal order;
Send-bounded traits; no tokio runtime in a simulation. - Seed-Driven Reproducibility: one
u64seed controls the run; RNG call counts; the opt-in determinism canary. - The Deterministic Executor: seeded task scheduling, task owners, the CPU gate, kill-on-drop.
- The Provider Pattern: the
Providersbundle;TokioProvidersandSimProviders. - Quick Start: Swapping Implementations: one generic function, run on Tokio and in the simulator; the forbidden calls.
- Deep Dive: Why Providers Exist: why not mocks, “never more permissive”, static dispatch.
- The Six Providers: Time, Task, Network, Random, Storage and System, plus
Resolverandselect!. - Same Code in Production and Simulation: no Sim* wrappers;
hint!,buggify!andcpu_chargeare no-ops in production; lean release builds. - System Under Test vs Test Driver: why
ProcessandWorkloadare two traits. - Process: Your Server: the factory, reboots, the context,
crash_self. - Workload: Your Test Driver: setup, run, check; the operation alphabet;
slownessand system samples.
Part III: Building Simulations
- Your First Simulation: the plan and the full target program.
- Defining a Process: accept loop,
select!on shutdown, ranged counts. - Writing a Workload: topology lookups, a reference model, why
run()must finish. - Configuring the SimulationBuilder: every builder method in one table, with links.
- Running and Observing: nextest vs xtask, the report, stop conditions, stall guards.
- Process Groups, Tags and Topology: several roles, IP ranges, tags,
.cluster()failure domains. - Chaos Testing vs Simulation: two schools of breaking things, and where faults enter.
- Chaos in Moonpool: every
Chaossurface,ChaosMode::RandomandSwarm, the chaos window. - Buggify: Fault Injection:
buggify!,buggify_with_prob!,buggify_named!,buggify_knob!, two-phase activation. - hint!: Naming Fault Sites: the code names a moment, the simulator decides; vetoes; the journal example.
- Swarm Testing: Masks and Budgets: fault masks, budgets,
swarm_operations, one workload with many behaviours. - Attrition: Process Reboots: reboot kinds,
max_dead, victims per group, failure domains. - Correlated Outages and Self-Crashes:
Chaos::Outage,OutageVeto,SimContext::crash_self. - Network Faults: the nine families, partitions, black holes, network masks.
- Storage Faults: storage layering, the barrier-bounded crash model, the lost-synced-write oracle.
- Aiming Storage Faults:
LayoutRegion, fault focus, targeted injection, replicated fault patterns. - CPU Time and Gray Failures: the CPU gate,
cpu_charge,SystemProvider,Chaos::GrayFailure, slow machines. - Custom Fault Injectors:
FaultInjector,fault_factory, theFaultContextmethods. - Assertions: Finding Bugs: the fifteen macros, where counts live, how assertions decide when to stop.
- Invariants vs Discovery vs Guidance: three jobs, and what each kind does in the runner and the explorer.
- Always and Sometimes: the five boolean macros.
- Numeric Assertions: the eight numeric macros and watermarks.
- Compound Assertions:
assert_sometimes_all!andassert_sometimes_each!. - Events and Invariants: plain
tracingevents, theInvarianttrait, the fault timeline. - Application Metrics: per-node Prometheus metrics, metric queries, a metastable failure.
- Designing Workloads That Find Bugs: operation alphabets, swarm, invariant patterns.
- Debugging a Failing Seed: the workflow.
- Reproducing with FixedCount: pin the seed, turn up the logs.
- Reading the Event Trace: event kinds, faults in the timeline, RNG call counts.
- Common Pitfalls: seventeen mistakes and their fixes.
- Discovering Properties: eight attention focuses to find what to assert.
Part IV: Simulating Existing Applications
- Using moonpool-sim Standalone: real HTTP over simulated TCP.
- Where to Draw the Line: fakes vs containers, per dependency.
- Wiring a Web Service: an axum service step by step.
- The hyper Stack: gRPC and HTTP:
H2Server,ReconnectingChannel, tonic. - What You’re Testing (and What You’re Not): the limits of the model.
- Using Providers in Production:
TokioProviders, the lean dependency tree, inert fault sites. - Migrating Existing Code to Providers: the call mapping and the gotchas.
- Simulation in the Browser: the wasm build and the live demo.
Part V: Raw Network Simulation
- Simulating the Network: TCP, not packets; who owns what.
- Typed RPC with moonpool-rpc: driver, endpoints, delivery modes, reply streams, failure monitor.
- What moonpool-rpc Promises: the promises and the seven campaigns that check them.
- Admission, Security and Derive: admission queues, buggify points, TLS, JWT, the derive macro.
Part VI: Building on Top
- Multiverse Exploration: fork at discovery.
- The Exploration Problem: the sequential luck problem.
- The Frontier Controller: recipes, the discovery journal, bug recipes.
- Bounded Workers: a fixed pool of forked workers.
- Exemplars and Continuations: semantic states and the under-explored frontier.
- Multi-Seed Exploration: exploration across many seeds.
- Exploring a Consensus Protocol: a worked procedure.
- A Crash-Aware Journal: moonpool-journal, its commit hints, and its tests under
Chaos::Storage.
Appendix
- Assertion Reference: every macro in one table.
- Crate Map: every crate, its features and dependencies.
- Configuration Reference: every config struct with defaults.
- Fault Reference: every fault family with defaults.
- Glossary: the terms.
- Sim Compatibility Checklist: ten checks before code runs in a simulation.
- Calibrating Against a Real Machine:
moonpool-calibratemeasures a disk and a network. - Running Simulation Binaries with xtask:
cargo xtask sim.
Guide for AI Agents
- The Rules
- Crates and Features
- Providers
- Process, Workload, Context
- SimulationBuilder
- SimulationReport
- Assertions
- Invariants and Traces
- Buggify and Hints
- Chaos Configuration
- FaultContext (custom injectors)
- Exploration
- Low-Level SimWorld
- Staged Adoption
- Code to Read First
This page is a dense map of moonpool for a coding agent that adds deterministic simulation to a system. Every name on it exists in the current source. When this page and the code disagree, the code wins: read the source and update this page with the change. The narrative chapters explain each mechanism; this page shows how to combine them.
The Rules
- Production code is generic over
P: Providers. It callstime.sleep,task.spawn_task,network.connect,storage.open,random.random_range,system.sample. Nevertokio::spawn,tokio::time,tokio::select!,std::fs,std::net,thread_rng,Instant::now. - The same code runs in production over
TokioProvidersand in simulation overSimProviders. NoSim*wrapper in application code. Processis the server, rebuilt by its factory on every reboot.Workloadis the test driver and survives reboots. Never merge them.- Facts go out as
tracingevents. Cross-process safety goes in anInvariant. Local checks go inassert_always!. Rare paths getassert_sometimes!. Dangerous moments getbuggify!orhint!. - Every random decision is a counted draw on one seeded stream.
BTreeMap, notHashMap. Futures areSend + 'static. Nounwrap(). - Replay before diagnosis:
set_debug_seeds(vec![seed]).set_iterations(1)orreplay_timeline(seed, recipe). - Never delete an assertion that catches a bug. Fix the bug.
- Validate with
nix develop --command cargo fmt,cargo clippy,cargo nextest run, andmdbook build book/when a public API changes.
Crates and Features
| Crate | Depend on it for | Features |
|---|---|---|
moonpool | The facade. moonpool_core::*, moonpool_sim::* under sim, modules hyper, prometheus, rpc. | default = ["sim", "tokio"], hyper, prometheus, rpc, rpc-derive, rpc-tls, rpc-tls-pki, rpc-jwt |
moonpool-core | Provider traits and core types. | default = ["tokio-providers", "select"], granular tokio-task/-time/-net/-fs/-random/-system, deterministic-select |
moonpool-sim | The runtime, builder, chaos, assertions, observability. | default = ["tokio-providers", "exploration"] |
moonpool-assertions, moonpool-buggify | Probes and fault sites for libraries. Zero dependencies. | none |
moonpool-explorer | Multiverse exploration (fork, mmap). | none |
moonpool-hyper, moonpool-rpc, moonpool-prometheus, moonpool-journal | HTTP/2, typed RPC, metrics, a CLSTORE journal. | see the Crate Map |
Production: moonpool = { default-features = false, features = ["tokio"] }. Simulation harness: moonpool-sim in dev-dependencies or a separate binary crate. wasm: moonpool-sim with default-features = false.
Providers
| Trait | Methods | Simulation type |
|---|---|---|
TimeProvider | sleep(d), now() -> Duration, timer() (drifted), timeout(d, fut) | SimTimeProvider |
TaskProvider | spawn_task(name, fut) -> JoinHandle, yield_now(); Detach | SimTaskProvider |
NetworkProvider | bind(addr), connect(addr); TcpStream: AsyncRead + AsyncWrite; TcpListenerTrait::{accept, local_addr} | SimNetworkProvider |
RandomProvider | random::<T>(), random_range(r), random_ratio(), random_bool(p) | SimRandomProvider |
StorageProvider | open(path, OpenOptions), exists, delete, rename, create_dir_all, list_dir, sync_dir | SimStorageProvider |
StorageFile | read_at, write_at, write_at_durable (durable on return, its range only), the zero-copy read_at_aligned / write_at_aligned / write_at_durable_aligned (an owned AlignedBuf moved in and handed back; Tokio moves it into the blocking task instead of copying through a bounce buffer; defaults to the borrowed form, which the sim keeps), sync_all, sync_data, size, set_len, allocate (reserve the space: fallocate(2) on Linux; the sim counts it against disk capacity), constraints(), is_direct_io() | simulated file image |
SystemProvider | sample(data_dir) -> SystemSample { at, process_cpu, cores, machine_busy, machine_total, run_loop_busy, disk: Option<DiskCounters>, space: Option<DiskSpace> } | SimSystemProvider |
Providers | network(), time(), task(), random(), storage(), system() | SimProviders |
Resolver | resolve(host_port) | ScriptedResolver |
Local* (non-Send) | LocalStorageProvider, LocalStorageFile, LocalNetworkProvider, LocalTcpListener, LocalTimeProvider, LocalProviders (no task slot): blanket impls from every Send trait. LocalTaskProvider::spawn_local_task(name, fut): no blanket impl; CompioTaskProvider, SimTaskProvider. Compio backend (features compio-*, umbrella compio-providers): CompioTaskProvider, CompioTimeProvider, CompioNetworkProvider, CompioStorageProvider (direct I/O and O_APPEND on Linux, same open policy as Tokio), CompioSystemProvider, bundle CompioProviders: LocalProviders, CompioResolver. ThreadProviders: LocalProviders adds a local task slot (local_task()): CompioProviders, SimProviders | the same sim types |
OpenOptions::direct_io(DirectIo::{Disabled, Optional, Required}), BlockFile<F> for block arithmetic over one open file (owned *_blocks_aligned forms for zero copy), AlignedBuf (zeroed, for_constraints, truncate, is_aligned_to), LayoutRegion { path, bytes, kind, stripe, shard } to name byte ranges. moonpool_sim::select! replaces tokio::select! with the same grammar. moonpool_sim::executor::spawn(name, fut) is the sim-only raw spawn.
Process, Workload, Context
#[async_trait]
impl Process for Node {
fn name(&self) -> &str { "node" }
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> { .. }
}
#[async_trait]
impl Workload for Driver {
fn name(&self) -> &str { "driver" }
async fn setup(&mut self, ctx: &SimContext) -> SimulationResult<()> { Ok(()) } // default
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> { .. }
async fn check(&mut self, ctx: &SimContext) -> SimulationResult<()> { Ok(()) } // default, after chaos
}
SimContext methods:
| Group | Methods |
|---|---|
| Providers | providers(), network(), time(), task(), random(), storage() |
| Local code | run_local(name, |ctx| fut): run a !Send future as a local task of the process, return its answer |
| Identity | my_ip(), client_id(), client_count(), peer(name), peers() |
| Topology | topology() → WorkloadTopology { groups, ips_in_group, group_for, my_group, peer_by_name, all_process_ips, ips_tagged, tags_for, my_tags, my_locality, locality_for, peers_on_my_machine, ips_in_domain } |
| Lifecycle | shutdown() (a CancellationToken), crash_self(kind, Option<Duration>), self_crash() -> SelfCrash |
| Deployment | deployment() -> &Deployment { spawn(group), spawn_in(group, DomainSpec), spawn_group(name, count, factory), retire(ip), version() }; refuses from a process; topology queries read the live Membership |
| Shared state | state() → StateHandle { publish(key, v), get::<T>(key), contains(key) } |
| Observation | observability() (a TraceQuery), disk_counters(ip), system_samples(ip), slowness(ip) |
| Metrics | metrics::<S>(), metrics_handle() |
SimulationBuilder
let report = SimulationBuilder::new()
.processes(3, || Box::new(Node::default())) // group 0 on 10.0.1.{1..3}
.tags(&[("role", &["leader", "follower"])])? // round-robin over the last group
.workloads(WorkloadCount::Fixed(2), |i| Box::new(Driver::new(i)))
.enable_chaos([
Chaos::Network(ChaosMode::Swarm),
Chaos::Storage(ChaosMode::Swarm),
Chaos::Attrition { config: attrition, mode: ChaosMode::Swarm },
])
.chaos_duration(Duration::from_secs(60))
.invariant(NoSplitBrain::new())
.run()?;
if report.is_failure() { report.eprint(); std::process::exit(1); }
| Method | Note |
|---|---|
processes(count, factory), cluster(LocalityConfig, factory) | count: usize or RangeInclusive<usize>. One call per role. |
tags(&[(key, &[values])]) -> Result | Over the last group. |
workload(w), workload_factory(f), workloads(WorkloadCount, f) | Exploration and check_determinism need a factory. |
enable_chaos([Chaos..]) | Network(mode), Storage(mode), Attrition { config, mode }, Outage { config, mode }, BuggifyKnobs, Cpu(mode), GrayFailure { config, mode }. |
chaos_duration(d) | Required by fault_factory. After it: recovery mode. |
chaos_until_run_ends() | Chaos ends when the last run() returns (chaos_duration is the cap); check() runs on live processes after the cutoff. chaos_ended_at() gives the cutoff. |
fault_factory(|| Box<dyn FaultInjector>) | Custom injector per run. |
network_fault_mask(m), storage_fault_mask(m) | Remove families after the draw. |
replicated_storage_faults(ReplicatedFaults) | Bound damage to tolerated domains. |
swarm_operations() | Per-seed operation subsets; test with swarm_op_enabled(op_id). |
link_latency(cfg), tcp_send_window_bytes(n), accept_backlog_capacity(n) | Deployment shape. Defaults none, 64 KiB, 128. |
max_spawns(n) | Cap on processes workloads spawn per run through ctx.deployment(). Default 16. |
invariant(i), invariant_fn(name, |q, t| ..), trace_level(level) | Invariants run after every step. |
metrics_factory(|ip| Arc<S>), metric(MetricQuery::select("m").label(k, v).rate().bucketize(d, Mean).reduce(Mean).named("x")) | App metrics in the report. |
set_iterations(n), until_coverage_stable(10, 1000) (default), set_debug_seeds(seeds), run_time_budget(d) (1 h) | Campaign control. |
enable_exploration(ExplorationConfig), replay_timeline(seed, recipe), check_determinism() | Exploration, replay, canary. |
No builder method accepts a NetworkConfiguration or StorageConfiguration. Without Chaos::Network the network still has FoundationDB’s always-on faults (random close, clock drift, buggified delay, probabilistic connect failure). Without Chaos::Storage storage has no faults.
SimulationReport
| Field | Meaning |
|---|---|
iterations, successful_runs, failed_runs, seeds_used, seeds_failing | Campaign outcome. is_failure() is true when a seed failed, an always-assertion was violated, or assertion slots were dropped. |
assertion_results, assertion_details, bucket_summaries, assertion_violations, coverage_violations, dropped_assertion_allocations | Assertion accounting. |
saturation: Option<SaturationReport { signal, edges_covered, edges_total, sometimes_hit, sometimes_total, plateau_seeds }>, convergence_timeout | How UntilCoverageStable stopped. |
exploration: Option<ExplorationReport { total_timelines, expansions, discoveries, bugs_found, bug_recipes, max_active_workers, sancov_edges_total, sancov_edges_covered, converged, per_seed_timelines }> | Exploration. |
metrics, individual_metrics, app_metrics, metric_queries, run_id | Timing and application metrics. |
report.eprint() prints it. success_rate(), average_wall_time(), average_simulated_time().
Assertions
| Macro | Role |
|---|---|
assert_always!(cond, "msg" [, { "k" => v }]), assert_always_or_unreachable! | Invariant. Violation fails the seed. |
assert_sometimes!(cond, "msg"), assert_reachable!("msg"), assert_unreachable!("msg") | Coverage. First pass is a discovery. |
assert_always_{greater_than, greater_than_or_equal_to, less_than, less_than_or_equal_to}!(val, thresh, "msg") | Bound. Watermark for reporting only. |
assert_sometimes_{..}!(val, thresh, "msg") | Depth. Watermark improvements guide the explorer. |
assert_sometimes_all!("msg", [("a", a), ("b", b)]) | Facts that must coincide. Frontier advances guide. |
assert_sometimes_each!("msg", [("phase", p)], [("quality", q)]) | Per-identity coverage. At most 6 keys, 256 buckets. No contract. |
reachable!("msg"), sometimes!(cond, "msg") | Probes from moonpool-assertions for libraries. |
Limits: 2048 sites, 256 buckets. Only sometimes-style assertions create discoveries. Full table: Assertion Reference.
Invariants and Traces
tracing::info!(target: "raft", term, leader = %my_ip, "leader_elected"); // emit, in process code
impl Invariant for NoSplitBrain {
fn name(&self) -> &'static str { "no_split_brain" }
fn observe(&self, q: &dyn TraceQuery, _sim_time_ms: u64) {
for e in q.since("leader_elected", &self.cursor) { // cursor: Cell<usize>
let term = e.u64("term"); let leader = e.str("leader");
assert_always!(.., "one leader per term");
}
}
fn reset(&mut self) { .. } // per seed
}
Capture rule: INFO and above, constant message, inside a process or workload task. TraceEvent { seq, time_ms, source, target, level, name, fields } with u64, i64, f64, bool, str accessors. TraceQuery { len(name), since(name, &cursor), snapshot(name) }. Sim faults appear as "sim_fault" events with a kind field. init_sim_tracing(Level) in a binary’s main.
Buggify and Hints
| Macro | Effect |
|---|---|
buggify!() | 25% at an active site (sites are active on half the seeds). |
buggify_with_prob!(p), buggify_fault_with_prob!(p) | p at an active site. The fault form is silent in recovery mode. |
buggify_named!("label", p) + set_activation("label", bool) | Named sites a harness can mask. |
buggify_pick!(p, n), buggify_range!(p, range) | Option<usize>, Option<u64>. |
buggify_knob!(default, lo..hi) | A knob value in range on some seeds (moonpool-sim). |
hint!("label"), hint!("label", p) | A crash moment. The attrition regime decides. HintVeto::new(|ip, label| bool).publish(ctx.state()) budgets it, and a per-process regime’s timed reboots too (label TIMED_REBOOT_LABEL). |
cpu_charge(Duration) | Explicit CPU work under Chaos::Cpu. |
All are no-ops in production. moonpool-journal shows the pattern: four hint! moments per batched commit (one after a failed sync writes the batch again) (an ordered commit’s two durable writes are named inside the simulated write_at_durable, “storage: durable write landed, not synced”), and two per roll onto a recycled spare segment.
Chaos Configuration
| Type | Construction |
|---|---|
Attrition | { max_dead, prob_graceful, prob_crash, prob_wipe, recovery_delay_ms: None (1–10 s), grace_period_ms: None (2–5 s), scope: AttritionScope::PerProcess, victims: AttritionVictims::Any, wipe_budget: None }. AttritionVictims::group("name"), ::tagged("key", "value"). .wipe_budget(max_unhealed, WipeHeal::Signal | WipeHeal::After(d)) makes wipes roll: a wiped process is unhealed until WipeLedger::heal(ctx.state(), ip) (or d after its restart), and a drawn wipe past max_unhealed unhealed in the pool becomes a crash. No extra draws. |
Outage | Outage::groups(["acceptors"]): probability 1.0, start 0–10 s, down 1–10 s, no straggler. OutageVeto::new(|victims| bool).publish(ctx.state()). |
GrayFailure | GrayFailure::new(max_slow): window 0.3–1.0 of the chaos window, cpu 10–1000, disk 5–100, network 2–20, GrayKinds::ALL. |
CpuModel | Drawn per seed under Chaos::Cpu; SimWorld::set_cpu_model by hand. |
NetworkFaultMask, StorageFaultMask | all(), none(), .with(f), .without(f). NetworkFault::{Clog, Partition, BitFlip, RandomClose, ConnectFailure, ClockDrift, BuggifiedDelay, PairLatency, BlackHole}. StorageFault::{Corruption, Eio, Misdirect, PhantomWrite, SyncFailure, ShortTransfer, DirEntryLoss, CrashDamage, Degradation, DiskFailure, DroppedMutationDiscard}. A dropped write, sync or set_len future still lands at its scheduled time (as on Tokio and io_uring) unless dropped_mutation_discard_probability discards it whole. A dropped read is cancelled. |
ReplicatedFaults | ReplicatedFaults::new(DomainLevel::Machine).tolerance(1).group("acceptors"). Patterns Minority, Striped, Rolling, Spared. |
FaultFocus | FaultFocus::new().background(0.0).layout(journal.regions().iter(), |r| if r.kind == Layout::RECORD { 1.0 } else { 0.0 }) (Journal::regions() returns Vec<LayoutRegion>). |
LocalityConfig | LocalityConfig::new(datacenters, zones, machines, processes) with cluster(..). |
ExplorationConfig | { workers: 0, max_runs_per_seed: 512, branching_factor: 4, max_frontier: 1024, max_recipe_len: 64 }. |
Every default and range: Configuration Reference. Every fault: Fault Reference.
FaultContext (custom injectors)
#[async_trait]
impl FaultInjector for Partitioner {
fn name(&self) -> &str { "partitioner" }
async fn inject(&mut self, ctx: &FaultContext) {
while !ctx.chaos_shutdown().is_cancelled() {
if let Some(ip) = ctx.reboot_random(RebootKind::Crash).ok().flatten() { .. }
ctx.time().sleep(Duration::from_secs(5)).await.ok();
}
}
}
| Group | Methods |
|---|---|
| Reboots | reboot(ip, kind), reboot_with_delays(..), crash(ip), crash_for(ip, down), restart(ip), reboot_random(kind), reboot_tagged(..), reboot_machine(..), reboot_domain(..), dead_count(), is_dead(ip) |
| Network | partition(a, b), heal_partition(a, b), is_partitioned(a, b) |
| Storage | durable_bytes(ip, path), corrupt_bytes(..), fail_file_with_eio(..), clear_file_eio(..), set_disk_capacity(ip, Option<DiskCapacity>), disk_space(ip) |
| Slowness | set_slowness(ip, Slowness), slowness(ip), cpu_model() |
| Topology | process_ips(), group_registry(), ips_in_group(name), machine_registry() (live, owned copies), membership(), membership_version() |
| Misc | state(), random(), time(), chaos_shutdown() |
Exploration
.workload_factory(|| Box::new(Driver::default()))
.enable_exploration(ExplorationConfig { workers: 0, max_runs_per_seed: 2400, branching_factor: 4, max_frontier: 512, max_recipe_len: 32 })
workers: 0 is deterministic and fork-free: use it in tests. workers > 0 only in a standalone binary run through cargo xtask sim run <name>. Bugs land in report.exploration.bug_recipes as BugRecipe { seed, recipe }; replay with replay_timeline(seed, recipe). format_timeline / parse_timeline convert "151@8837201 -> 80@1293847". Guidance comes from sometimes-style assertions only. See Multiverse Exploration.
Low-Level SimWorld
For provider tests without the runner: SimWorld::new_with_seed(seed), new_with_network_config(cfg), set_storage_config, set_process_storage_config(ip, cfg), storage_provider(ip), network_provider(ip), time_provider(), step(), has_pending_events(), run_until_empty(), take_faults(), partition_pair(from, to, d), corrupt_file(path, sectors), fail_file_with_eio(path, sectors, EioTarget), simulate_crash_for_process(ip, close_files), set_fault_focus, publish_layout. Drive futures with moonpool_sim::executor::Executor::new(seed).block_on(..) and step the world on Pending (the drive helper in the root AGENTS.md).
Staged Adoption
- Boundary. Make the code generic over
P: Providers. Replace every forbidden call (Sim Compatibility Checklist). Run one seed with no chaos. Addcheck_determinism()and keep it green. - Driver and oracle. Write the
Workloadwith a small reference model (BTreeMapof expected state, not a copy of the server). Reconcile ambiguous results: a timed-out write may have committed. - Facts and invariants. Emit
tracingevents for every protocol decision. Write cursor-based invariants for agreement, validity, persistence, prefix, client semantics. - Guidance. Add
assert_sometimes!on every retry, timeout and recovery path. Add watermarks for depth. Keep bucket vocabularies coarse. - Chaos. Enable
Chaos::Network(Swarm), thenStorage(Swarm), thenAttrition, thenOutage,Cpu,GrayFailure. Addhint!moments in storage code. Addbuggify!at dangerous choices. Mask what the harness cannot observe. - Campaign. Keep the default
UntilCoverageStable. Register the binary in xtask (Running Simulation Binaries with xtask). Add exploration when a bug needs a sequence of rare events. - Debug. Pin the seed with
set_debug_seeds,set_iterations(1),trace_level(LevelFilter::ERROR). Read the event trace. Fix the root cause. Re-enable chaos.
Code to Read First
| Topic | File |
|---|---|
| Invariant from traces | crates/moonpool-sim/tests/leader_election.rs |
| Process groups, tags, topology | crates/moonpool-sim/tests/process_groups.rs, crates/moonpool-sim-examples/src/topology.rs |
| Spawn and retire mid-run | crates/moonpool-sim/tests/deployment.rs, crates/moonpool-sim-examples/src/operator.rs |
| Swarm operations | crates/moonpool-sim/tests/swarm_op_alphabet.rs |
| Custom injectors, targeted faults | crates/moonpool-sim/tests/scripted_faults.rs, targeted_faults.rs |
| Hints, outages, self crash | crates/moonpool-sim/tests/hint.rs, outage.rs, crash_self.rs |
| Storage faults and crash model | crates/moonpool-sim/tests/storage.rs, crates/moonpool-sim-examples/src/journal.rs |
| CPU and gray failure | crates/moonpool-sim/tests/cpu.rs |
| Exploration | crates/moonpool-sim-examples/src/maze.rs, dungeon.rs, crates/moonpool-sim/tests/exploration.rs |
| Metrics | crates/moonpool-sim/tests/metric_queries.rs, crates/moonpool-sim-examples/src/metrics_service.rs |
| HTTP and gRPC | crates/moonpool-sim-examples/src/axum_web.rs, tonic_grpc.rs |
| Determinism | crates/moonpool-sim/tests/determinism.rs, determinism_canary.rs |
| RPC campaigns | crates/moonpool-rpc-sim/src/ |
State of Moonpool
Moonpool is a hobby project under active development. It is not production-ready. There is no backward-compatibility policy: when an API changes, the old one is deleted, not deprecated. The workspace is at version 0.8.0. This book documents the framework as it exists today and marks what is new.
The crates
The workspace holds sixteen crates. Ten are published to crates.io. Six are tools and examples that stay in the repository. The Crate Map has the dependency graph.
| Crate | What it is | Status |
|---|---|---|
moonpool | The facade. Features sim and tokio are on by default. hyper, prometheus, rpc, rpc-derive, rpc-tls and rpc-jwt are opt-in. A lean production build uses default-features = false, features = ["tokio"]. | Stable |
moonpool-core | The six provider traits (TimeProvider, TaskProvider, NetworkProvider, RandomProvider, StorageProvider, SystemProvider), TokioProviders, select!, LayoutRegion, storage types. Granular tokio-* features keep it wasm-clean. | Stable. SystemProvider and LayoutRegion are new. |
moonpool-sim | The simulation engine: SimWorld, the deterministic executor, SimulationBuilder, chaos, the 15 assertion macros, events and invariants. Compiles to wasm32-unknown-unknown with --no-default-features. | Stable core. See the new features below. |
moonpool-buggify | buggify!, buggify_with_prob!, buggify_named!, hint! and cpu_charge. Pure std, zero dependencies, inert outside a simulation. | Stable. hint!, buggify_named!, set_activation and cpu_charge are new. |
moonpool-assertions | Antithesis-style assertion accounting: 2048 slots, 256 buckets, the heap table the explorer overlays with shared memory. Pure std, wasm-able. | Stable |
moonpool-explorer | Fork-based frontier exploration: recipes, the frontier controller, bounded workers, sancov edge coverage. Linux and macOS only. | Stable. Multi-process parallel exploration is still on the list. |
moonpool-hyper | hyper 1.x on the providers: HyperExecutor, HyperTimer, HyperIo, TowerToHyperService. Runs tonic, axum and plain hyper unchanged. | Stable |
moonpool-rpc | Typed dynamic-endpoint RPC in the style of FoundationDB’s fdbrpc: interfaces as data, delivery modes, streams, per-endpoint admission queues, buggify points inside the runtime. Features prost, derive, tls, jwt. | New |
moonpool-rpc-derive | The #[moonpool_rpc::service] attribute. | New |
moonpool-prometheus | PrometheusSource: the metrics your code already keeps, reported per node and across seeds. | Stable |
moonpool-journal | A CLSTORE journal: positions in any order, crash told apart from corruption, damage identified for repair from peers. | New. Rewritten this cycle. |
moonpool-rpc-sim | The simulation harness, oracles and six CI campaigns for moonpool-rpc. Not published. | New |
moonpool-sim-examples | Runnable simulations found by xtask: maze, dungeon, axum, tonic, journal, topology, metrics. Not published. | Stable |
moonpool-wasm-demo | The browser demo embedded in Simulation in the Browser. Not published. | Stable |
moonpool-calibrate | Measures a real host’s disk and network and prints LatencyDistribution constants. Not published. | Stable |
xtask | `cargo xtask sim list | run |
What is stable
The simulation engine is the oldest and most exercised part. One OS thread, one seeded random stream, logical time that jumps when every task is blocked. The same seed gives the same execution, and SimulationBuilder::check_determinism runs every seed twice to check that claim. The engine runs the example simulations and the RPC campaigns in CI on every commit.
Chaos covers the network (partitions, latency, close, corruption, partial writes), storage (corruption, torn writes, misdirection, sync failures, stalls, the barrier-bounded crash model) and process attrition (graceful reboots and crashes). ChaosMode::Swarm enables a random subset of fault families per seed. buggify_knob! pushes configuration knobs to extremes.
The assertion suite has the 15 macros of the Antithesis taxonomy: always, sometimes, reachable, unreachable, eight numeric forms, assert_sometimes_all! and assert_sometimes_each!. The explorer forks a child at every discovery and keeps the accounting in shared memory.
Events and invariants are plain tracing events. SimulationLayer captures them, and invariants read them through TraceQuery after every step.
Integration runs real HTTP/2, axum and tonic traffic through moonpool-hyper, and the simulator runs in the browser through wasm.
What is new
These landed in the last cycle. Each has a chapter.
hint!: shipped code names a moment, and the simulator decides what goes wrong there.HintVetolets a harness cap how many hints strike. See hint!: Naming Fault Sites.buggify_named!andset_activation: per-seed scenarios driven by named sites. See Buggify.Chaos::OutageandOutageVeto: a correlated outage takes a whole process group down at one instant, and a harness can defer it.SimContext::crash_selfandSelfCrashlet a workload crash its own process. A.cluster()group may draw zero processes. See Correlated Outages and Self-Crashes.- CPU time and gray failures:
Chaos::Cpu,Chaos::GrayFailure,cpu_chargeandSimContext::slowness. A machine stays alive but slow.SystemProviderexposes CPU and disk counters to the code under test. See CPU Time and Gray Failures. - Aimed storage faults:
storage_fault_mask, owner-scoped and byte-range targeted injection,LayoutRegion,FaultContext::durable_bytes, and the replicated patternsMinority,StripedandRollingper process group. See Aiming Storage Faults. moonpool-journalv2: a position-keyed CLSTORE journal withBatch::fitsandfits_another,Journal::peek_meta, anAmbiguoustail and hints inside a commit. See A Crash-Aware Journal.moonpool-rpc: admission queues per endpoint, thederive,tlsandjwtfeatures, and themoonpool-rpc-simcampaigns. See Typed RPC with moonpool-rpc.
What is missing
Multi-process parallel exploration across CPU cores is not implemented. The explorer runs one controller and a bounded pool of short-lived forked workers. There is no deterministic hypervisor: code that bypasses the providers is not simulated.
How to read this book
Part I is motivation. You can read it without touching code, and the ideas apply to any simulation framework. Parts II through VI follow the current API. Every example is adapted from a test or an example in the repository. Part VI describes the most novel piece, fork-based exploration. If you are evaluating whether exploration matters for your system, start there.
The Case for Simulation
- The gap
- The combinatorial problem
- Why coverage numbers lie
- The structural argument
- What simulation needs
You deploy on a Friday afternoon. The tests are green. Code review was thorough. You ran the integration suite twice. You go home.
At 2am your phone lights up. A network partition isolated two nodes for eleven seconds. During recovery, a message arrived out of order. A retry collided with a timeout. The system entered a state nobody on the team had imagined. Data was lost.
The code was correct for the world your tests described. It was not correct for the world production delivered.
The gap
Development environments are clean. The network is localhost. Disks never fail. Clocks agree. Messages arrive in order, exactly once. We know this is fiction. Our test environments reproduce the fiction anyway. We test against a world that does not exist, then we are surprised when the real world finds the bugs we missed.
Production is different. A study of 198 failures in Cassandra, HBase, HDFS, MapReduce and Redis found that 74% were deterministic. Most could be reproduced on three nodes or fewer. 77% could have been caught by a unit test that checked the error path. The bugs were not exotic. They were ordinary mistakes in error-handling code that nobody tested. A later study of network partition failures showed that 80% of catastrophic failures in distributed systems involve a partition, and 27% of those lose data. These are not edge cases. They are Tuesday.
The gap between development and production is not a small oversight. More careful tests do not close it. It is structural.
The combinatorial problem
Take a modest system: three nodes, a leader election, replicated state. Now list what can go wrong. Any node can crash and restart. The network between any pair of nodes can partition. Messages can be delayed, reordered or duplicated. Disk writes can be torn or lost. Clocks can drift.
One scenario looks like this: “Node B crashes during an election while Node A has a write in flight and the link between A and C drops for two seconds.” That is one scenario. How many are there?
With coarse modeling, three nodes, five failure types and ten time steps give thousands of distinct failure histories. Five nodes with realistic granularity give millions. That is before application state: which data was in flight, which transactions were uncommitted, which clients were retrying.
The same problem appears in ordinary services. An e-commerce API with six dimensions (user type, payment method, delivery option, promotion, inventory status, currency) needs 648 combinations for basic coverage. One more option per dimension pushes it past 4,000. A real system has hundreds of dimensions. On one real project, a 300-line feature needed a 10,000-line test PR to keep combinatorial coverage.
This is not a tooling problem. No test framework makes 10,000 hand-written tests sustainable. The state space of a distributed system grows faster than any team can write tests for it.
Why coverage numbers lie
A coverage report says 85% line coverage and 70% branch coverage. These numbers measure how much code the tests execute. They say nothing about how much state space the tests explore.
A distributed system can run the same lines in thousands of orderings with thousands of timing relationships. Line coverage treats all of them as one. Branch coverage is a little better but still blind to interleaving. You can have 100% branch coverage and never test what happens when an election overlaps with a partition during a compaction.
Will Wilson put it precisely: humans cannot enumerate all control-flow paths, which is why we need tests. The same limit makes it impossible for humans to write complete tests. This is not a failure of discipline. Manual tests verify what developers imagined. They cannot verify what developers did not imagine. Bugs live in the places nobody imagined.
The structural argument
Here is the argument in its shortest form.
- Distributed systems fail in ways that depend on the order of concurrent events.
- The number of orderings grows combinatorially with system size.
- Humans write tests for scenarios they can imagine.
- The scenarios that cause bugs are the ones nobody imagined.
So complete manual testing of a distributed system is not hard. It is impossible.
This does not mean we stop testing. It means we need a different kind of test. Instead of writing scenarios by hand, we generate them. Instead of a clean environment, we test in one that is worse than production. Instead of hoping we covered the important cases, we build infrastructure that explores the failure space for us.
That is what simulation gives us. Not better tests. A different kind of testing.
What simulation needs
Three properties make this kind of testing work. The rest of Part I and all of Part II come back to them.
Determinism. The whole system runs on one thread with one seeded random source. The same seed gives the same execution, so a failure is a number you can replay. See Determinism as a Foundation.
Control of the environment. Time, tasks, network, disk and randomness go through traits with two implementations: the real one for production, the simulated one for tests. The simulated one is where faults are injected. See The Provider Pattern.
Properties, not expectations. Tests state what must always hold and what must sometimes happen. The infrastructure generates the scenarios and checks the properties. See Prevention vs Discovery.
Prevention vs Discovery
- Prevention: guarding what you know
- Discovery: finding what you do not know
- Three principles that enable discovery
- The feedback loop
- Guidance: a third job for assertions
- A spectrum, not a binary
Most teams treat testing as one activity. Write tests, run tests, read the results. But two different activities hide under that one word, and mixing them up is one of the most expensive mistakes in software engineering.
Prevention: guarding what you know
The first kind of testing is prevention. You ship a feature. A user reports a bug. You fix it and write a test that would have caught it. The next time someone touches that code, the test fails before the bug can return.
This is regression testing. It is valuable, well understood and nearly universal. CI pipelines run thousands of these tests on every commit. The mental model is defensive: we build a wall around known-good behavior, one brick per bug report.
Prevention answers one question: did we break what already worked?
Discovery: finding what you do not know
The second kind of testing is discovery. Discovery does not guard known behavior. It searches for failure modes nobody has found yet. It asks: what else is broken?
Prevention tests encode scenarios a developer imagined. Discovery tests generate scenarios no developer imagined. Prevention is a wall. Discovery is a search party sent into unmapped territory.
Most teams have a portfolio that is 100% prevention and 0% discovery. Every test answers a requirement or a bug report. No test was written to find bugs the team does not know about. This is not a criticism. Until recently, discovery at scale was available only to teams that could spend years on infrastructure. The tools have caught up.
Three principles that enable discovery
What does it take to move from prevention to discovery? Three principles, each built on the one before.
Deterministic simulation. Discovery generates huge numbers of scenarios and must replay any that fail. If a bug needs a specific order of network events, we need that exact order again. Determinism makes every execution reproducible from one seed. A failing seed is a bug report anyone on the team can replay. Without determinism, discovery produces ghosts.
Controlled fault injection. Discovery must exercise the paths production hits and development does not: partitions, disk corruption, reordering, clock skew, a crash in the middle of recovery. These are the normal operating conditions of a system at scale. Controlled injection means the simulator decides when and how faults occur, from the same seed, so every fault is reproducible and tunable.
Sometimes assertions. This is the subtle one. Prevention uses assertions that say “this must always be true.” Discovery needs a second kind: “this should sometimes be true.” A sometimes assertion on a retry path does not check that retries work every time. It checks that the simulation reached the retry path at all. Without it, you can run a million simulations and never know whether they explored the interesting parts of the state space or repeated the happy path a million times.
In moonpool these are assert_always! and assert_sometimes!, two of the 15 macros in Assertions: Finding Bugs.
The feedback loop
Put the three principles together. Simulation generates scenarios. Fault injection pushes them into failure territory. Always assertions catch violations. Sometimes assertions tell us whether we explored deeply enough.
When a sometimes assertion never fires, the simulation is not reaching some region of the state space. That is a signal to change something: enable another fault family, change the workload, add concurrent operations. When every sometimes assertion fires and every always assertion holds, we have evidence (not proof) that the system handles the failure modes we care about.
Moonpool turns this loop into the default stop condition. IterationControl::UntilCoverageStable runs seed after seed until every sometimes assertion has fired and code coverage has stopped growing for plateau_seeds seeds in a row. See Running and Observing.
The developer’s job changes. Instead of writing one test per scenario, you define correctness properties and read the results. The infrastructure does the exploration.
Lawrie Green of Antithesis described assertions as memos to two audiences. They tell the computer what to check. They tell the developer what they believe about their system. When an assertion fails on correct code, the developer’s mental model was wrong. That is a finding too.
Guidance: a third job for assertions
Discovery has one more use for assertions. When a simulation reaches a state it has never seen before (a sometimes assertion fires for the first time, a numeric watermark improves), that moment is worth more exploration. Moonpool’s explorer forks the process at exactly that point and continues from the new state with fresh randomness. The assertion is no longer only a check. It is a signal that guides the search. See Invariants vs Discovery vs Guidance and Multiverse Exploration.
A spectrum, not a binary
Prevention and discovery do not compete. Every team needs prevention. Adding even a small amount of discovery changes what you find.
You do not have to jump from zero to full deterministic simulation. Random delays in an integration test are discovery. Property-based tests with random inputs are discovery. One sometimes assertion in an existing simulation is discovery.
The question is not whether to do discovery testing. The question is how far along the spectrum to go. Each step finds bugs that prevention, by construction, never will.
From Mocks to Simulation
- Why mocks fail at scale
- The
#[cfg(test)]trap - The alternative: trait-based simulation
- The fidelity spectrum
- Injected conditions over expected calls
- Where the code names a moment
- Moonpool’s provider pattern
Every experienced developer has a mock story. You spend a day writing mocks for a database client. You specify which method returns what, in which order. The tests pass. Then someone refactors the internal call sequence without changing any external behavior, and every mock breaks. The mocks were not testing correctness. They were testing implementation details.
This is not the fault of any mocking library. It is a structural problem with how mocks work.
Why mocks fail at scale
A mock replaces a dependency with a fake that returns pre-programmed responses. To program those responses, you must know which methods your code calls, in which order, with which arguments. The test author carries a mental model of the whole call stack between the code under test and the mocked dependency.
For a unit test of one function, that is manageable. For an integration test of a distributed protocol with concurrent operations, retries, timeouts and failure handling, it is a maintenance nightmare. Every internal refactor breaks mocks that tested behavior. Every new failure path needs new mock responses. The mock setup grows until it rivals the system it tests.
Mocks also skip layers. Production code talks to a TCP socket. The mock replaces the database client. Between those two layers live connection pooling, serialization, retry logic, timeout handling and error translation. None of that code runs in a mock-based test. You test a different system from the one you deploy.
The #[cfg(test)] trap
Rust developers often reach for conditional compilation. #[cfg(test)] swaps in a test-only implementation with no runtime cost and no trait indirection. But the binary you test is now a different binary from the one you deploy. Different code paths, different struct fields, different behavior.
Suppose a bug lives in the interaction between your retry logic and your connection pool. Your test build replaces the pool with an in-memory stub under #[cfg(test)]. The bug is invisible to the test suite. You have not tested the system. You have tested a system-shaped thing that shares some code with it.
The same trap has a second form: a SimMachine next to Machine, a SimDisk next to Disk, a #[cfg(test)] hook that lets the test reach inside. Each one is a second implementation that the tests exercise and production never runs.
The alternative: trait-based simulation
There is a different approach. Define a trait that describes the interface your code needs. Implement it once for production (real TCP, real disk, real clock). Implement it once for simulation (simulated network, simulated disk, simulated clock). Application code depends on the trait. The simulated implementation runs real logic, not canned responses, so every path production exercises, simulation exercises too.
#![allow(unused)]
fn main() {
pub trait TimeProvider: Clone + Send + Sync + 'static {
fn sleep(&self, duration: Duration)
-> impl Future<Output = Result<(), TimeError>> + Send;
fn now(&self) -> Duration;
}
}
In production, sleep calls tokio::time::sleep. In simulation, sleep registers a timer with the simulated scheduler, and logical time jumps forward when every task is blocked. The application code is identical. No #[cfg(test)]. No conditional compilation.
This is the provider pattern. FoundationDB made the same decision with its INetwork interface: one interface, two implementations (Net2 for production, Sim2 for simulation), zero conditional logic in the database.
The fidelity spectrum
Not every simulated implementation needs full fidelity. There is a spectrum.
No-op. sleep returns at once, send discards the message. Useful for pure logic that happens to call I/O.
In-memory. Messages go into a queue, disk writes into a BTreeMap. Fast and deterministic, but no timing, no failures, no reordering.
Controlled simulation. Messages are delayed by random amounts, connections drop on a fault schedule, disk writes can be torn or corrupted. This is where bugs hide, because the system must handle every way the real world deviates from the happy path. These implementations scale: you write each one once, and every component that uses the trait gets simulation for free. No per-test setup.
Full simulation. A whole cluster of processes with simulated topology, coordinated fault injection and time compression. FoundationDB runs hundreds of simulated processes on one thread and compresses hours of cluster behavior into seconds.
Pick the level by what you test. A serialization function needs no-op I/O. A retry loop needs controlled failure injection. A consensus protocol needs full cluster simulation.
Injected conditions over expected calls
The deepest difference between mocks and simulation is the direction of control.
A mock specifies outputs: “when this method is called with these arguments, return this value.” The author must predict every call. If the code takes another path, the mock panics.
A simulation injects conditions: “connections close with this probability, connects take 1 to 11 ms, a sector written since the last sync may be lost after a crash.” The simulation does not care which methods run or in which order. It cares whether the system recovers correctly whatever happens.
Mock-based tests verify that code follows a specific path. Simulation-based tests verify that code produces correct results on every path the simulation explores. One tests implementation. The other tests behavior.
Where the code names a moment
There is one place where the code under test does talk to the simulator, and it is worth being precise about it. A trait swap cannot know that “the batch is durable but its messages are not sent yet” is an interesting instant. Only the code knows that.
Moonpool lets shipped code name such a moment with hint!("batch durable, not sent").await. The code never names a fault. In production the hint is a no-op. In simulation the simulator decides whether to strike there and how, from the fault families the seed enabled. The code’s own rare-but-legal choices use buggify!: an early timeout, a small buffer, a slow path. Both are inert outside a simulation, so the shipped binary carries them at no cost. See hint!: Naming Fault Sites and Buggify.
Moonpool’s provider pattern
This is what moonpool implements. Every interaction with the outside world goes through one of six traits in moonpool-core: TimeProvider for clocks and timers, TaskProvider for spawning, NetworkProvider for connections, StorageProvider for files, RandomProvider for randomness, SystemProvider for CPU and disk counters. The Providers trait bundles them, and TokioProviders is the production bundle.
Your code calls time.sleep() instead of tokio::time::sleep(), and task.spawn_task() instead of tokio::spawn(). In production these call the real runtime. In simulation they feed a deterministic scheduler where every timer, message and disk operation is controlled, reproducible and open to fault injection.
No mocks to maintain. No #[cfg(test)] to split your binary. The same code, in a world that is deliberately worse than reality. Part II covers the traits in The Six Providers and the rule behind them in Same Code in Production and Simulation.
A Brief History
- FoundationDB’s bet (2009-2015)
- Antithesis generalizes it (2020 to now)
- The adoption wave (2020 to now)
- Where moonpool draws from
- The unifying philosophy
Simulation-driven development did not appear from nowhere. It grew over fifteen years across three eras. Each era widened who could use it and how.
FoundationDB’s bet (2009-2015)
In 2009 a small team set out to build a distributed transactional database. Before they wrote the database, they spent about two years on a simulator. No storage engine, no query layer, no client protocol. Just the simulation.
The thesis was simple and, to most observers, insane. Distributed systems fail in ways that depend on the order of concurrent events. The only way to test those orders systematically is to control them completely.
They built Flow, a C++ extension that compiles actor-style code to single-threaded callbacks. Every connection, disk operation and timer goes through one interface, INetwork, with two implementations: Net2 for production (real TCP over Boost.ASIO) and Sim2 for simulation (in-memory buffers with deterministic delays). One seeded random number generator controls everything. Same seed, same execution, every time.
Two techniques made the simulation unusually effective. BUGGIFY marks points in the code where a rare but legal behavior can be forced: a packet sent out of order, a truncated disk write, a skipped timeout, a shrunk buffer. Each site is activated at random once per run and then fires with a configurable probability. BUGGIFY widens the contract under test far beyond what any specification documents. It tests not what the system is supposed to handle but what it can handle.
Correlated failures came from a Hurst-exponent model. In a real datacenter, a failing drive makes its rack neighbors more likely to fail. Independent-failure models make cascades astronomically rare. FoundationDB turned the correlation up so that failure patterns a datacenter sees once in years appear in simulation in milliseconds.
The scale was large. Tens of thousands of simulations every night, each simulating minutes to hours of cluster time under heavy fault injection. A physical cluster called Sinkhole, real motherboards wired to programmable power switches that toggled continuously, served as the reality check. Sinkhole never found a database bug that the simulator had missed. It found bugs in other software and in hardware.
The cost was equally large: a custom language, a simulation-first architecture and two years of infrastructure before the product. For more than a decade FoundationDB’s simulation was a benchmark others admired but could not copy.
Antithesis generalizes it (2020 to now)
Will Wilson, one of the original FoundationDB engineers, co-founded Antithesis around a different question: can the same techniques apply to any software, without a rewrite?
The answer was a deterministic hypervisor. Instead of building simulation into the system from day one, Antithesis intercepts nondeterminism at the virtual machine. Any program in any language becomes a pure function from an input byte stream to a sequence of states. Save, restore, fork, replay. No Flow. No custom language. No architectural prerequisite.
On top of the hypervisor sits a guided exploration engine. The SDK provides declarative assertions that say what to explore without saying how: Sometimes (reach this condition at least once), Always (this invariant holds), SometimesAll (drive the search across a frontier of sub-goals). The platform combines these signals with coverage-guided forking and adaptive search.
The results validated the approach. Customers with mature, well-tested systems found new bugs within two to three weeks of onboarding: deep concurrency issues, subtle data corruption, recovery defects that survived years of conventional testing. The engine needs little domain knowledge. In one demonstration Antithesis beat the game Gradius by watching three bytes of memory and maximizing time alive.
The adoption wave (2020 to now)
The third era is happening now. Simulation-driven development is spreading beyond the teams that invented it.
TigerBeetle, a financial transactions database, adopted deterministic simulation from day one. Its simulator adds storage faults drawn from real hardware failure modes: misdirected reads and writes, phantom writes, torn writes, read corruption. Dropbox used simulation to validate its sync engine rewrite. WarpStream applied deterministic simulation to its whole SaaS platform. In Rust, the madsim crate showed that a tokio-compatible deterministic runtime could drive existing async code.
Clever Cloud uses simulation-driven development to build Materia, a distributed multi-model multi-tenant database. Thirty minutes of simulation covers roughly 24 hours of equivalent chaos testing.
What took an elite team two years of custom infrastructure in 2009 is becoming available to small teams on ordinary languages and frameworks. The investment is shrinking. The bugs found per engineering hour are growing.
Where moonpool draws from
Moonpool takes one idea from each era, and the rest of the book names the source whenever it matters.
From FoundationDB: the single-threaded simulator with one random stream, INetwork as the provider pattern, BUGGIFY as buggify! and hint!, the simulated worker’s please_reboot as attrition, and fdbrpc as the model for moonpool-rpc.
From TigerBeetle: the storage fault model, from torn and misdirected writes to phantom writes and sync failures, and the discipline of aiming faults so a replica set keeps a clean copy.
From Antithesis: the assertion taxonomy and the idea that a discovery is a point worth forking from.
From the paper behind CLSTORE (Alagappan et al., FAST ’18): a journal that tells a crash from corruption and names what was damaged, built on the simulator’s storage faults.
The unifying philosophy
Across all three eras the philosophy is the same. Make execution deterministic so bugs are reproducible. Inject faults that are worse than production so a system that survives is overqualified for the real world. Explore systematically so bugs are found by infrastructure, not by imagination.
The tools change. The languages change. The accessibility changes. The philosophy does not.
Why Moonpool Exists
- What already existed
- Where moonpool sits
- Same code in production and simulation
- What moonpool offers today
- What makes it different
After ten years of operating distributed systems, one pattern keeps repeating: the bugs that hurt most are the ones nobody imagined.
You build a system. You test the scenarios you can think of. You deploy. Then a partition overlaps with a retry storm during a rolling upgrade, and the system enters a state nobody on the team anticipated. The code was not wrong. It was never tested against that combination of events.
The combinatorial problem from The Case for Simulation makes this inevitable. You cannot test what you do not know. Manual tests give regression coverage, not absence of bugs. That realization led to moonpool: the testing toolbox I wish I had when I started building distributed systems.
What already existed
The answer was scattered across several projects and two decades of work. FoundationDB showed that a whole cluster can run on one thread with one seeded random stream, and that fault injection on top of that finds more bugs in a night than production finds in a year. TigerBeetle took the same idea to the disk, with faults drawn from real hardware failure modes. Antithesis turned assertions into a declarative SDK and used them to guide a search that forks at every discovery. A Brief History tells that story.
Nobody had put these pieces together as a Rust library you add with one line in Cargo.toml. That is the gap moonpool fills.
Where moonpool sits
Moonpool is a library-level simulation framework. There is no hypervisor, no custom virtual machine, no special runtime. You add a dependency, write your code against six provider traits, and your system becomes simulatable. Simulations run as ordinary cargo test invocations in your CI pipeline.
This is a deliberate trade. Antithesis’s hypervisor simulates any software without modification. Moonpool asks that your I/O go through the providers, which means designing for simulation from the start or refactoring existing code (Migrating Existing Code to Providers). In exchange you get zero-cost abstractions in production, full control over fault injection, and no service to call.
Same code in production and simulation
One rule shapes every API in moonpool: the code you test is the code you ship. Shipped code is never wrapped in a Sim* variant. There is no SimMachine, no SimDisk, no test-only hook. Code depends on provider traits. TokioProviders backs them in production, and the simulator’s providers back them in tests.
When shipped code knows that an instant is interesting, it names the moment with hint!("label").await. In production that is a no-op. In simulation the simulator decides whether to strike there and how, under the seed’s chaos, its swarm masks and its budgets. The code’s own rare-but-legal choices use buggify!, buggify_with_prob! or buggify_named!, inert outside a simulation. The rule and its consequences are in Same Code in Production and Simulation.
What moonpool offers today
Every feature below has a chapter. This is the map.
Six provider traits. TimeProvider, TaskProvider, NetworkProvider, RandomProvider, StorageProvider and SystemProvider in moonpool-core, bundled by the Providers trait. TokioProviders is the production bundle. The traits are Send + Sync, so your code keeps its Arc<RwLock<_>> and DashMap. See The Provider Pattern and The Six Providers.
A deterministic executor. One OS thread, seeded task scheduling, logical time that jumps when every task is blocked. moonpool_sim::select! replaces tokio::select! so branch order comes from the seed, not from process entropy. See The Deterministic Executor.
Process and Workload. A Process is the system under test. The simulator creates a fresh one on every boot. A Workload is the test driver. It survives reboots, drives requests and checks results. See System Under Test vs Test Driver.
The SimulationBuilder. .processes() and .cluster() register process groups on their own subnets, .tags() spreads roles, .enable_chaos() picks surfaces, .chaos_duration() bounds the window, .invariant() and .metrics_factory() add observers, .until_coverage_stable() and .set_debug_seeds() control iterations, and .run() returns a SimulationReport. See Configuring the SimulationBuilder and Process Groups, Tags and Topology.
Chaos surfaces. Chaos::Network, Chaos::Storage, Chaos::Attrition, Chaos::Outage, Chaos::BuggifyKnobs, Chaos::Cpu and Chaos::GrayFailure, each with a ChaosMode. ChaosMode::Random turns everything on. ChaosMode::Swarm enables a random subset per seed. See Chaos in Moonpool.
Buggify and hints. buggify! sites are activated once per run and fire with a probability. buggify_named! with set_activation builds per-seed scenarios. buggify_knob! pushes a configuration knob to an extreme. hint! names a moment and leaves the fault to the simulator, with HintVeto as a harness budget. See Buggify and hint!.
Swarm testing. One workload, many behaviors. network_fault_mask, storage_fault_mask and swarm_operations give each seed its own subset of fault families and operations, so a seed with few faults reaches deep states and a seed with many faults stresses recovery. See Swarm Testing.
Attrition, outages and self-crashes. Attrition reboots processes gracefully or by crash under a regime with a max_dead budget. Chaos::Outage takes a whole group down at one instant, and OutageVeto lets the harness defer it. SimContext::crash_self lets a workload crash its own process and get a SelfCrash handle. See Attrition and Correlated Outages and Self-Crashes.
Network faults. Connect delays, random close, bit flips, partial writes, packet loss and partitions, all on real byte streams through NetworkProvider. See Network Faults and Simulating the Network.
Storage faults. One StorageProvider, one StorageFile, OpenOptions::direct_io for database-shaped needs and BlockFile for block arithmetic. Faults follow TigerBeetle and FoundationDB: corruption, torn and misdirected writes, phantom writes, sync failures, stalls, and the barrier-bounded crash model where every sector written since the last sync resolves on its own. A lost-synced-write oracle fails any run where a sector a sync called durable changes across a crash. See Storage Faults.
Aimed storage faults. Injection scoped to an owner and a byte range, LayoutRegion as a format-neutral map, and ReplicatedFaults patterns (Minority, Striped, Rolling) that damage replicas while a clean copy survives somewhere. See Aiming Storage Faults.
CPU time and gray failures. Chaos::Cpu charges every poll to the process’s one core. cpu_charge lets shipped code declare a cost. Chaos::GrayFailure keeps a machine alive and slow, and SimContext::slowness tells a workload about it. SystemProvider exposes the counters your code reads to detect it. See CPU Time and Gray Failures.
Custom fault injectors. fault_factory builds a fresh FaultInjector per timeline, with a FaultContext that can crash, restart, read durable_bytes and read workload state. See Custom Fault Injectors.
Fifteen assertion macros. assert_always!, assert_sometimes!, assert_reachable!, assert_unreachable!, assert_always_or_unreachable!, eight numeric forms, assert_sometimes_all! and assert_sometimes_each!. See Assertions: Finding Bugs and the Assertion Reference.
Events and invariants. Correctness facts are plain tracing events. SimulationLayer captures them into a timeline, and an Invariant reads them through TraceQuery after every step. The same events feed production observability. See Events and Invariants.
Metrics. moonpool-prometheus scrapes the counters, gauges and histograms your code already keeps into the report, per node and across seeds. See Application Metrics.
Debugging. A failing seed is a number. set_debug_seeds with set_iterations(1) replays it, and the event trace reads backwards from the first violation. See Debugging a Failing Seed.
HTTP and gRPC. moonpool-hyper supplies HyperExecutor, HyperTimer and HyperIo, so axum, tonic and plain hyper run unchanged on the providers. See The hyper Stack and Wiring a Web Service.
Typed RPC. moonpool-rpc is FoundationDB’s fdbrpc without its wire format: interfaces as data, delivery modes, streams, per-endpoint EndpointQueue admission, buggify sites inside the runtime, and derive, tls and jwt features. moonpool-rpc-sim runs six campaigns in CI. See Typed RPC with moonpool-rpc and Admission, Security and Derive.
Production and the browser. The lean build default-features = false, features = ["tokio"] keeps the simulator out of your release binary. The simulator itself compiles to wasm32-unknown-unknown, and the book embeds a browser demo. See Using Providers in Production and Simulation in the Browser.
Multiverse exploration. moonpool-explorer forks the process at every discovery, records a replay recipe of (rng_call_count, seed) breakpoints, and schedules bounded continuations through a pool of short-lived workers. Coverage comes from sancov edges under cargo xtask sim run. See Multiverse Exploration.
A crash-aware journal. moonpool-journal implements CLSTORE over the providers: positions in any order, a crash told apart from corruption, damage named for repair from peers, and hint! inside every commit. See A Crash-Aware Journal.
Tooling. cargo xtask sim list | run | run-all runs the simulation binaries, and moonpool-calibrate measures a real host to produce latency constants. See Running Simulation Binaries with xtask and Calibrating Against a Real Machine.
What makes it different
Many frameworks stop at network-level fault injection. Moonpool adds three things.
First, storage simulation at the same fidelity as the network, aimed so that replicated data keeps a clean copy and the system has something to repair from.
Second, fault sites in shipped code. hint! lets the code name the moment and the simulator pick the fault, so the production binary carries its own test plan at no cost.
Third, fork-based exploration. The sequential luck problem (a bug that needs n unlikely events in a row has probability p^n) is the central bottleneck of simulation testing. Instead of hoping one timeline walks through every door, moonpool forks at each door and explores both sides.
Moonpool is not only a simulation engine. It is a simulation engine with a search strategy for the state spaces where bugs need a sequence of unlikely events to appear.
Determinism as a Foundation
A distributed system dropped writes for 47 seconds. The logs show three nodes that disagreed about leadership. The window has passed. The cluster healed itself. You cannot reproduce the failure on your laptop. You cannot reproduce it in staging. You spend three days in the logs, you form a theory, you ship a fix you are 60% sure about, and you wait for it to happen again.
This is debugging without determinism. The bug is real, but the conditions that caused it are gone. You cannot replay the execution. You cannot step through it. You cannot even confirm that your fix addresses the right cause.
The Three Enemies
A Rust program that uses async networking has three sources of non-determinism: thread scheduling, real I/O and random numbers.
Thread scheduling. The OS decides when a thread runs. Two threads that race on a shared counter increment it correctly 999,999 times, then corrupt it once. The interleaving that triggers the bug depends on CPU load, on thermal throttling, on how many browser tabs are open. A multi-threaded tokio runtime moves tasks across a thread pool. The order in which tasks resume after an .await belongs to the scheduler. Run the program twice and you get two execution orders.
Real I/O. The outside world injects randomness. A TCP packet arrives 2 ms late. A DNS lookup takes 800 ms instead of 5 ms. A disk write returns ENOSPC because another process filled the partition. Your code is a deterministic function, but its inputs are chaos.
Random numbers. rand::rng() pulls entropy from the OS. Two runs with identical inputs make different random choices: different retry delays, different leader elections, different shard assignments.
A distributed-systems bug usually needs all three at once: a specific interleaving, during a specific network delay, with a specific disk latency. To reproduce the bug you must reproduce all three. That is close to impossible.
Moonpool’s Answer
Moonpool brings all three sources under one seed.
One thread, one executor. A simulation runs on moonpool_sim::executor::Executor, a single-threaded executor that moonpool owns. There is no tokio runtime inside a simulation. When several tasks are ready, the executor picks the next one with a seeded draw. The Single-Core Constraint and The Deterministic Executor explain this.
Providers instead of raw I/O. Every system call your code makes goes through a trait: TimeProvider, TaskProvider, NetworkProvider, RandomProvider, StorageProvider and SystemProvider. In production the trait calls tokio. In simulation the trait calls the simulated world. The code in between is identical. The Provider Pattern covers this.
One random stream. The simulation has exactly one ChaCha8Rng, seeded from a u64. The executor’s scheduling draws, the select! branch offsets, the network and storage fault coins, buggify activation, the per-seed swarm masks and every RandomProvider call your code makes come from that one stream. Moonpool keeps no private RNG for its own decisions. Seed-Driven Reproducibility covers this.
With all three under control, a simulation is a pure function of its seed. The same seed gives the same execution, bit for bit. A different seed explores a different interleaving, a different fault schedule, a different set of random choices.
Where Time Lives
The event side of the guarantee is explicit. One Scheduler<Event> owns logical time, cancellation and a monotonic sequence number. Events with the same timestamp run in sequence order, so same-time events are FIFO without any dependence on heap internals. Scheduling into the past clamps to the current time.
The scheduler does not own the simulated resources. NetworkSimulation owns connections, topology, network faults and network wakers. StorageEngine owns files, open handles, disk episodes and storage wakers. Each component consumes its own events and returns ordered effects for the coordinator, SimWorld, to schedule. Random decisions happen inside the component that owns the state. Global ordering happens in one scheduler. Wakers run only after the world lock is released. This split is what makes replay exact: there is one place where “what happens next” is decided.
What This Gives You
A failing seed turns a production incident into a local debugging session. Instead of “it happened once between nodes 7 and 12 under load”, the report ends with:
Faulty seeds: [8839214571]
You pin that seed with set_debug_seeds(vec![8839214571]), run one iteration, and set a breakpoint. The bug is there, every time. You fix it. You run 10,000 more seeds. They pass.
This is not aspirational. FoundationDB ran 5 to 10 million simulation iterations per night, each with a different seed. Their physical validation cluster, real servers with programmable power switches, never found a database bug that simulation had missed. Determinism made that possible.
Everything in moonpool builds on this foundation. Chaos, assertion coverage, swarm testing, multiverse exploration: all of it rests on one guarantee. Given the same seed, the simulation produces the same result. The rest of this part explains how moonpool keeps that guarantee.
The Single-Core Constraint
Moonpool runs every simulation on one OS thread. This is not a limitation we accept with regret. It is the first design decision, and every other decision follows from it.
One Thread, One Order
A multi-threaded tokio runtime uses a work-stealing thread pool. When a task yields at an .await, any thread in the pool can pick it up. Two tasks that resume “at the same time” run in either order, and the order changes between runs. This is fine for production throughput. It is fatal for a simulation that must replay.
With one thread there is exactly one execution order for a given set of ready tasks. Task A runs before task B, or B before A, and our scheduler makes the choice, not the OS. The RNG picks the order. The seed determines the RNG. Done.
How We Get There
Every simulation runs on the moonpool deterministic executor:
moonpool_sim::reset_sim_rng();
moonpool_sim::set_sim_seed(seed);
let mut executor = moonpool_sim::executor::Executor::new(seed);
executor.block_on(async move {
// the entire simulation runs here
});
One OS thread drives the whole simulation. There is no work-stealing pool and no preemption between yield points. When a future resumes, it resumes on the thread that suspended it. Which ready task runs next is a seeded draw from the ready queue. The same seed gives the same order, bit for bit. A different seed explores a different interleaving. A FIFO scheduler would run ready tasks in registration order on every seed, forever, and would hide every race between them. The Deterministic Executor covers the mechanism.
There is no tokio runtime inside a simulation. A bare tokio::spawn panics at runtime, because there is no runtime to spawn onto. Spawn through ctx.task().spawn_task() or moonpool_sim::executor::spawn(). Sleep through ctx.time().sleep(). Select with moonpool_sim::select!.
Send-Bounded Traits
Here moonpool diverges from earlier single-threaded designs. A single-threaded runtime could drop Send bounds and let you put Rc<RefCell<T>> across .await points. We deliberately do not.
Provider traits use native async-fn-in-trait with Send + Sync + 'static supertraits, and every async method returns impl Future<…> + Send:
pub trait TimeProvider: Clone + Send + Sync + 'static {
fn sleep(&self, duration: Duration) -> impl Future<Output = Result<(), TimeError>> + Send;
fn now(&self) -> Duration;
// ...
}
The traits the runner stores as dyn objects (Process, Workload, FaultInjector) use #[async_trait] without ?Send, with the same Send + Sync + 'static supertraits.
We pay for Send to get interoperability, not parallelism.
The runtime still runs on one thread. No future ever moves between threads at runtime. But the type system commits to Send + Sync at every trait boundary, so your code can use the ecosystem it already knows:
Arc<RwLock<State>>for shared stateDashMapfor concurrent mapsArc<AtomicBool>for flagsSend-bounded spawning:ctx.task().spawn_task("name", fut)in the sim,tokio::spawnin production
A team that adopts moonpool to test an existing service should not rewrite its type hierarchy. If the production code uses Arc<RwLock<…>> (it does), moonpool runs it as is. The simulation drives those types on one thread, but the types look exactly like production.
There is no LocalSet, no spawn_local and no build_local anywhere in moonpool. Spawned futures are Send + 'static, as tokio::spawn requires.
What This Costs
Send + Sync rules out a few patterns: raw Rc, and non-Send futures from external crates. In practice the patterns that matter (locks, channels, spawned tasks) are already Send-friendly.
The other cost is CPU parallelism inside one run. A simulation of 20 nodes runs on one core, not 20. This rarely matters. Simulated time is compressed: one core simulates hours of cluster time in seconds of wall-clock time, because the scheduler jumps straight to the next event. And we run many simulations in parallel across cores, each with its own seed. A 16-core machine runs 16 independent simulations at once, each deterministic on its own thread.
Production code is unaffected. The same code that runs single-threaded in the simulation runs on a multi-threaded tokio runtime in production. The provider pattern, covered next, makes that swap invisible to the code.
Seed-Driven Reproducibility
One number. A single u64. That is all moonpool needs to determine a simulation in full: which connections fail, when packets arrive, which buggify sites fire, how long disk writes take, which task polls next. Same seed, same execution, same bugs. This property makes everything else work.
The RNG Core
Moonpool uses ChaCha8Rng from the rand_chacha crate, seeded from the u64. ChaCha8 is fast, produces good randomness and is deterministic across platforms. The generator lives in thread-local storage. That is correct because each simulation runs on one thread, and parallel test threads never share a stream.
// The runner does this at the start of every iteration
moonpool_sim::reset_sim_rng();
moonpool_sim::set_sim_seed(seed);
// Inside the engine, every decision draws from the thread-local stream
let latency: u64 = sim_random_range(1..50); // a message delay
let fail: bool = sim_random_bool(0.25); // a fault coin, always one draw
let ratio: f64 = sim_random_f64(); // FDB's random01()
sim_random, sim_random_range, sim_random_f64 and sim_random_bool are the framework’s own entry points. Your application code uses providers.random().random() and providers.random().random_range() instead. The simulation’s SimRandomProvider routes those calls to the same stream.
There is exactly one stream. The executor’s choice of the next task, the select! start offset, the per-seed swarm masks, buggify activation and firing, every network, storage and CPU fault coin, and every RandomProvider call your code makes all draw from the same generator. Moonpool keeps no private stream for its own decisions. So the order of draws matters. Add one sim_random() call anywhere and every later value shifts. This is intended: a small code change gives a different trajectory and explores new state.
Call Count Tracking
Every draw increments a thread-local counter, readable with rng_call_count(). This sounds mundane. It is one of the most useful debugging tools in the framework.
When a seed produces a bug, “the bug triggers after draw 847” tells you which decision started the chain of events. set_rng_breakpoints(vec![(count, new_seed)]) reseeds the stream when the count passes count. The explorer records these count@seed pairs at each discovery and replays an exact path with SimulationBuilder::replay_timeline(seed, recipe). Because scheduling and select! offsets are draws on the same stream, a recipe replays the task order along with the faults. Multiverse Exploration builds on this.
The Determinism Canary
Everything above assumes nothing escapes the seed. Something always tries to: a HashMap iterated in its randomized order, a static that survives from one iteration into the next, a wall-clock timestamp, an OS random number from a dependency. Each one makes the simulation draw something different, or draw at a different moment, and the seed stops reproducing. Moonpool ships a canary for exactly this, the mechanism madsim’s Runtime::check_determinism uses:
SimulationBuilder::new()
.check_determinism()
.workload_factory(|| Box::new(MyWorkload::new()))
.run()?;
Every seed now runs twice. During the first run, each draw records a 64-bit fingerprint: a probe of the generator’s state after the draw, taken on a clone so the check consumes no randomness, mixed with the logical clock. During the second run the same hook compares each draw against the record. At the end the whole record must have been consumed. Any uncontrolled difference in scheduling, draw count or simulated timing shows up as a mismatch, and the seed fails with the always-assertion determinism canary: replay matched the recorded draw sequence, naming the first diverging draw. A replay that stops early fails too. The replay is also judged as a full run: a workload error, an always-violation or a deadlock fails the seed even when every fingerprint matched.
Two things to know. It is a canary, not a trace: it says that the runs diverged and at which draw. The call-count tools take it from there. And it doubles the cost of every seed, so it belongs in a dedicated CI job or a debugging session. Like exploration it needs factory workloads (workload_factory or workloads), because an instance workload would carry its state into the second run.
The end-to-end tripwire for moonpool itself is crates/moonpool-sim/tests/determinism.rs: two full SimulationBuilder runs of racing workloads on the same seed must produce byte-identical traces.
Many Seeds
One seed tests one path. Confidence needs many. The builder has two iteration modes.
UntilCoverageStable is the default: plateau_seeds: 10, max_iterations: 1000. The run keeps drawing seeds until every observed assert_sometimes! and assert_reachable! has fired and code coverage has not grown for 10 consecutive seeds, capped at 1,000. Under cargo xtask sim run the coverage signal is real sancov edge coverage. Otherwise it is assertion-slot coverage. Tune it with:
SimulationBuilder::new()
.until_coverage_stable(10, 5_000) // 10 quiet seeds, 5,000 cap
FixedCount runs a set number of seeds:
SimulationBuilder::new()
.set_iterations(100)
Each seed also draws its own swarm: with ChaosMode::Swarm a seed enables a random subset of fault families, and swarm_operations() enables a random subset of each workload’s operations. Two seeds therefore differ in which behaviours are even possible, not only in timing. Swarm Testing explains why this finds more bugs than every fault on at once.
Seed-driven testing compounds. Run 1,000 seeds in CI on every commit. Run 100,000 overnight. A bug that needs three unlikely events to coincide surfaces within a few thousand seeds, because buggify amplifies the probability of each event.
Debugging a Failing Seed
When a run fails, the report lists the seeds:
Faulty seeds: [17429853261]
Pin that seed and run it alone:
SimulationBuilder::new()
.set_iterations(1)
.set_debug_seeds(vec![17429853261])
.trace_level(tracing::level_filters::LevelFilter::DEBUG)
// ... the same processes and workloads ...
.run()?;
The simulation replays the exact same execution. trace_level changes what the timeline captures and never changes scheduling or randomness, so the seed replays identically at any level. Set a breakpoint. Step through. The bug is deterministic now. Debugging a Failing Seed walks through the full procedure.
The Deterministic Executor
- Why Not tokio?
- Randomized and Deterministic
- Where Is the Reactor?
- The Drive Loop
- Task Owners and the CPU Gate
- Coming from tokio
- Kill-on-Drop
- Exploration Process Safety
- Verifying It Yourself
Moonpool simulations do not run on tokio. They run on moonpool_sim::executor::Executor, a single-threaded executor whose every scheduling decision derives from the iteration seed. This chapter explains why it exists, how it works, and what changes if you come from tokio.
Why Not tokio?
Two reasons, one practical and one fundamental.
The practical one: seeding tokio’s runtime randomness needs the unstable RngSeed API, which forces --cfg tokio_unstable onto every crate in every consumer’s workspace. For a project pinned to stable Rust that is a hard adoption barrier.
The fundamental one: tokio’s current-thread scheduler is a FIFO queue, and FIFO is the wrong policy for a bug-finding simulator. When one event makes two tasks runnable, FIFO runs them in registration order, on every seed, forever. Any race between those two tasks is structurally unexplorable. No number of seeds will ever try the other order. A simulation executor must treat “which runnable task goes next” as a decision to explore, like a message latency or a fault timing.
Randomized and Deterministic
These two properties sound contradictory. They are not. Deterministic means “same seed, same execution, bit for bit”, not “one fixed schedule”. The executor keeps its ready tasks in a Vec and picks the next one with swap_remove at a seeded-random index. madsim uses the same distribution, and FoundationDB’s sim2 randomizes the same way:
seed 42: task-3, task-0, task-2, task-1, ...
seed 42: task-3, task-0, task-2, task-1, ... (always)
seed 43: task-1, task-3, task-0, task-2, ...
Each seed replays exactly. The population of seeds covers the ordering space. Ordering bugs, the kind that survive code review because both orders look fine, live exactly there.
The scheduling index is a draw on the simulation’s one random stream, the same sim_random_range a process reaches through ctx.random(). There is no private scheduling RNG. Which task runs next is counted like every other decision, so an exploration recipe replays the schedule along with the faults. The executor draws nothing when exactly one task is runnable: that is a forced choice, not a decision, and the dominant one-event-wakes-one-task path leaves the stream untouched.
Where Is the Reactor?
There is none. A production runtime pairs its executor with a reactor (epoll, a timer wheel) that turns OS events into waker calls. In the simulation, SimWorld and its Scheduler<Event> play that role. The scheduler owns logical time, stable same-time ordering and cancellation. It dispatches events to NetworkSimulation and StorageEngine, which own their resource state and their waker registries. The wake batches they return are invoked after the world lock is released. The executor answers one question only: of the tasks runnable right now, which one runs next?
The Drive Loop
Executor::block_on(main) pins the orchestrator future on the stack as the driver. It never enters the ready queue and is never scheduled randomly:
loop {
poll driver ── Ready? ─────────────► return
run_until_stalled() // poll ready tasks in seeded-random order
// until none is runnable
(driver not woken AND queue empty) ► panic: deadlock (with the seed)
}
The orchestrator’s step loop interleaves the world with the task pool:
sim.step(); // process one virtual event
crate::executor::until_stalled().await; // let every woken task run
The driver is re-polled only after a complete drain. So when until_stalled().await returns, every task that was runnable has been polled to Pending or to completion. Under tokio this was approximated with yield_now and FIFO ordering. Under randomized scheduling “the back of the queue” does not exist, so the executor provides the contract directly.
Inside a task, use executor::yield_now() instead. It reschedules the task at a seeded-random position. until_stalled() is driver-only and asserts it in every build profile: a task that awaits “run until nothing is runnable” waits for itself.
One consequence deserves a warning sign. A task must not busy-wait on progress that only the driver can make. A loop { yield_now().await } that polls a flag set by a simulation event keeps the ready queue non-empty forever, so the drain never finishes and sim.step() never runs. The executor kills such a loop after 1,000,000 polls in one drain, with a panic that names the task and the seed. Park on a real wake instead: a sleep, a Notify, a channel.
Task Owners and the CPU Gate
Every task carries metadata: an id, a name, and an owner. The owner is the IP of the simulated process whose root task spawned it, inherited by every task spawned while one of its tasks polls. The driver, the workloads and the fault injectors have no owner.
The owner serves two features. A hint!("label").await in shipped code finds the process to reboot through the owner of the task being polled, without being handed one. And with the CPU model on (Chaos::Cpu), a process’s task polls only when its one CPU slot is free. Otherwise the world parks the runnable and schedules a CpuFree event. A parked task is not runnable, so until_stalled keeps its contract. Tasks without an owner never pay. CPU Time and Gray Failures covers the model.
Coming from tokio
The task API mirrors what the orchestrator, and your process or workload code through TaskProvider, expected from tokio:
| tokio | moonpool executor |
|---|---|
tokio::spawn(fut) | executor::spawn("name", fut) (named, for seed debugging) |
JoinHandle::is_finished() | same |
JoinHandle::abort() | same (await yields Err(JoinError::Cancelled)) |
dropping a JoinHandle | same: detaches, the task keeps running |
| fire-and-forget | JoinHandle::detach(), the explicit form of the above |
| task panics | caught, the handle yields Err(JoinError::Panicked), siblings unaffected |
dropping the Runtime | dropping the Executor cancels every live task |
tokio::select! | moonpool_sim::select! (tokio’s own expansion, seeded start offset) |
Spawned futures stay Send + 'static. Execution is one OS thread, but the bounds let application code use Arc<RwLock<…>> and DashMap without contortion.
Panic handling has two layers. A task spawned with executor::spawn records its panic in its own JoinHandle. A task spawned through ctx.task().spawn_task() carries one more contract: the runner records an unobserved child panic with its process or workload identity, as an unobserved_task_panic event, and marks the seed as failed. If the caller awaits the handle and handles Err(JoinError::Panicked), the panic is observed and the seed can recover. Dropping or detaching the handle leaves the panic unobserved. A process’s tasks are also bound to the scope of its boot: when the process is killed, every task it spawned, however deep, is dropped with it. That drop is cancellation, not a panic.
time.sleep(Duration::ZERO) schedules a real same-time timer, so a burst of immediate work keeps the scheduler’s FIFO order. The runner allows the fan-out, then requests shutdown after 100,000 consecutive events at one logical instant. If the tasks ignore shutdown, the seed fails as a deadlock. Time advancing resets the count, so long timer-driven runs keep working.
Two behaviours are deliberately better than tokio’s:
- A genuine deadlock (driver not woken, nothing runnable) panics with the seed in the message instead of parking the thread forever.
- A task that stays runnable forever, a busy-yield loop or a
select!over a source it re-readies synchronously, trips the poll bound in every build profile instead of hanging the drain.
Kill-on-Drop
Simulation iterations must be hermetic: a task leaked by seed N must never run during seed N+1. Dropping the per-iteration tokio runtime used to guarantee that. The executor replicates it with a waker registry, the pattern async-executor uses. Every live task registers one waker. Drop wakes them all in task-id order, which schedules every parked task, then pops and drops Runnables until the queue is empty. Runnables the CPU model parked are dropped too. Dropping a Runnable cancels its task and drops its future. Any task that drop wakes lands in the same queue and the same loop consumes it.
Exploration Process Safety
The explorer forks only between complete timelines, after the executor and all of its tasks have been dropped. Assertion macros record discovery coordinates. They are not fork points. Forked workers are still opt-in, because unrelated host threads may hold locks that the child inherits. The default workers: 0 mode runs continuations in-process and is the right choice for CI.
Verifying It Yourself
crates/moonpool-sim/tests/executor.rs enforces the contracts: join, abort, detach, panic isolation, kill-on-drop, same-seed replay and cross-seed diversity. crates/moonpool-sim/tests/determinism.rs is the end-to-end tripwire: two full SimulationBuilder runs of racing workloads on the same seed must produce byte-identical traces. If any component, the executor, select! or a provider, ever consults an unseeded source, that test fails.
The Provider Pattern
- The Core Idea
- The Providers Bundle
- One Line Changes Everything
- Thin Providers, Stateful Engines
- What Stays Outside the Bundle
Every distributed system does five things with the outside world: it reads the clock, it spawns concurrent tasks, it talks over the network, it draws random numbers, and it reads and writes files. That is the whole surface where non-determinism leaks in. Moonpool seals all five behind traits, and adds a sixth trait that reads how busy the process and its disk are, so that measurement runs in simulation too.
The Core Idea
Define a trait for each category of I/O. Implement the trait twice: once over real tokio calls for production, once over the deterministic world for testing. Your application code is generic over the trait. It never knows which implementation it runs against.
Application Code
(generic over P: Providers)
|
+-----------+-----------+
| |
TokioProviders SimProviders
(real TCP, wall (simulated TCP,
clock, real disk, logical clock,
OS entropy) fault-injected disk,
seeded RNG)
This is interface swapping, the technique FoundationDB uses with its INetwork interface: Net2 in production, Sim2 in simulation. The difference is that Rust enforces it at compile time. If your code compiles with P: Providers, it works with both implementations.
The Providers Bundle
Six type parameters through every signature would be painful:
// Nobody wants to write this
fn run_server<N, T, K, R, S, Y>(net: N, time: T, task: K, rand: R, storage: S, sys: Y)
where
N: NetworkProvider, T: TimeProvider, K: TaskProvider,
R: RandomProvider, S: StorageProvider, Y: SystemProvider,
{ /* ... */ }
Moonpool bundles them in one trait, Providers:
pub trait Providers: Clone + Send + Sync + 'static {
type Network: NetworkProvider;
type Time: TimeProvider;
type Task: TaskProvider;
type Random: RandomProvider;
type Storage: StorageProvider;
type System: SystemProvider;
fn network(&self) -> &Self::Network;
fn time(&self) -> &Self::Time;
fn task(&self) -> &Self::Task;
fn random(&self) -> &Self::Random;
fn storage(&self) -> &Self::Storage;
fn system(&self) -> &Self::System;
}
Now your code carries one type parameter:
fn run_server<P: Providers>(providers: P) {
let time = providers.time().clone();
let net = providers.network().clone();
// use them like tokio
}
Two implementations exist. TokioProviders::new() in moonpool-core is production. SimProviders::new(sim, ip) in moonpool-sim is simulation: it takes a weak handle on the SimWorld and the IP of the process it serves, so its network, storage and system providers are scoped to that process’s machine. Your application code imports neither. It sees P: Providers and nothing else.
Each provider in the bundle is Clone. Clone the one you need and move it into a spawned task, the way you would clone an Arc.
One Line Changes Everything
The swap between production and simulation happens at the call site, not inside the logic:
// Production
let providers = TokioProviders::new();
run_server(providers);
// Simulation: inside a Process or Workload, the runner hands out SimProviders
let providers = ctx.providers().clone();
run_server(providers);
Same run_server. Same code path. The only difference is which Providers is plugged in. This is the foundation that everything else in moonpool stands on. Chaos, assertion coverage, hints, exploration: all of it rests on the guarantee that the shipped code runs unmodified inside the simulator. Same Code in Production and Simulation states that rule in full.
Thin Providers, Stateful Engines
On the simulation side each provider is deliberately thin. SimTimeProvider::sleep schedules a timer in the global Scheduler<Event>. SimNetworkProvider::connect asks NetworkSimulation for a delayed connect operation. SimStorageProvider::open asks StorageEngine. The engines own the state, the exact pending results, the faults and the wakers. They return scheduling effects to SimWorld, which puts them on the one timeline. The application-facing trait stays familiar, and the simulator never collapses into one global state object.
What Stays Outside the Bundle
Name resolution is a separate trait, Resolver, with TokioResolver in production and ScriptedResolver in simulation. Most code never resolves a name, and code that does picks its resolver next to its providers. moonpool_core::select! is a macro, not a provider: it is tokio’s own select! expansion with a seeded start offset once moonpool-sim is in the build graph. The Six Providers lists every trait, method by method.
Quick Start: Swapping Implementations
Here is what providers look like in practice. We write one function that uses the time and random providers, run it on tokio, then run the same function under simulation. The code is adapted from crates/moonpool/examples/retrying_worker.rs, which you can run both ways.
A Function Generic Over Providers
use moonpool::prelude::*;
use std::time::Duration;
/// Retry with exponential backoff and jitter, through providers only.
async fn fetch_with_retry<P: Providers>(providers: &P, max_attempts: u32) -> Result<u32, String> {
let time = providers.time();
let random = providers.random();
for attempt in 0..max_attempts {
// Stand-in for a fallible network call: succeeds 60% of the time.
// In the sim this coin comes from the seeded stream.
if random.random_bool(0.6) {
return Ok(attempt);
}
// Every sleep goes through the time provider, so the simulation
// advances logical time instead of waiting.
let base_ms = 10u64 << attempt;
let jitter_ms = random.random_range(0..base_ms.max(1));
time.sleep(Duration::from_millis(base_ms + jitter_ms))
.await
.map_err(|e| e.to_string())?;
}
Err(format!("gave up after {max_attempts} attempts"))
}
Notice what is not in this code: no tokio::time::sleep(), no rand::rng(). The function uses providers.time().sleep() and providers.random().random_bool(). That is the whole discipline. moonpool::prelude::* brings the provider traits into scope, which you need because their async fns are only callable with the trait imported.
The Forbidden List
These direct calls break determinism. Never use them in code that will run under simulation:
| Forbidden | Use instead |
|---|---|
tokio::time::sleep() | providers.time().sleep() |
tokio::time::timeout() | providers.time().timeout() |
std::time::Instant::now() | providers.time().now() |
tokio::spawn() | providers.task().spawn_task("name", fut) |
tokio::task::yield_now() | providers.task().yield_now() |
tokio::select! | moonpool::select! (seeded branch offset) |
tokio::net::TcpStream::connect() | providers.network().connect() |
tokio::net::TcpListener::bind() | providers.network().bind() |
tokio::net::lookup_host() | a Resolver |
tokio::fs::* | providers.storage().open() and friends |
rand::rng() | providers.random().random() |
getrusage, /proc/diskstats | providers.system().sample(data_dir) |
A direct tokio call is a hole in the simulation. It uses real I/O or real time, and the simulation has no control over it. The result is two different runs for one seed. Inside a simulation a bare tokio::spawn does not even run: there is no tokio runtime, so it panics.
tokio::select! is the subtle one. Its fair mode draws a start offset from tokio’s thread-local RNG, which is seeded from process entropy outside a tokio runtime. moonpool::select! is tokio’s own macro expansion with the offset drawn from the simulation stream, so the grammar is identical and only the number source changes. Outside a simulation it behaves like tokio’s. The Six Providers has the details.
Running in Production
use moonpool::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let providers = TokioProviders::new();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let result = runtime.block_on(fetch_with_retry(&providers, 5));
println!("production run finished: {result:?}");
Ok(())
}
TokioProviders bundles TokioTimeProvider, TokioTaskProvider, TokioNetworkProvider, TokioRandomProvider, TokioStorageProvider and TokioSystemProvider. Each one delegates to tokio, rand or the kernel. Real sleeps, real sockets, OS entropy.
Running in Simulation
A workload pulls SimProviders from its context and hands it to the same function:
use async_trait::async_trait;
use moonpool::{SimContext, SimulationBuilder, SimulationResult, Workload};
struct RetryWorkload;
#[async_trait]
impl Workload for RetryWorkload {
fn name(&self) -> &'static str {
"retry-client"
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
// Either outcome is fine: we assert the worker never panics or hangs
// under simulated time, across many seeds.
let _ = fetch_with_retry(ctx.providers(), 5).await;
Ok(())
}
}
#[test]
fn survives_chaos() {
let report = SimulationBuilder::new()
.set_iterations(50)
.workload(RetryWorkload)
.run()
.expect("simulation configuration is valid");
assert_eq!(report.failed_runs, 0, "worker should survive every seed");
}
Same function call. Now sleep() advances simulation time, so 50 seeds with their backoff schedules finish in milliseconds. random_bool(0.6) draws from the seeded stream, so a failing seed replays the same sequence of outcomes. Run both from the example:
cargo run --example retrying_worker # production, on tokio
cargo test --example retrying_worker # simulation, 50 seeds
That is the whole workflow. Write code generic over P: Providers, call provider methods instead of tokio, and let the runner pick the implementation.
Deep Dive: Why Providers Exist
- Why Not
#[cfg(test)] - Why Not Mocks
- Never More Permissive
- The Pattern
- Why Static Dispatch
- What You Get
Providers are not the first idea a team reaches for. Most teams start with #[cfg(test)] or a mock framework. Both have a fundamental problem that providers solve.
Why Not #[cfg(test)]
Conditional compilation is tempting. Keep the real call in production, swap a fake in during tests:
#[cfg(not(test))]
async fn connect(addr: &str) -> io::Result<TcpStream> {
TcpStream::connect(addr).await
}
#[cfg(test)]
async fn connect(addr: &str) -> io::Result<TcpStream> {
FakeStream::new() // returns at once, no network
}
The problem: you no longer test your production code. The test binary compiles a different function. Every #[cfg(test)] block is a fork where production and test behaviour can diverge in silence. Oxide’s engineers documented this across five large repositories: #[cfg(test)] blocks “prevent testing real code paths”. The tested code is not the shipped code.
Providers remove the fork. There is one connect() in your application. It calls providers.network().connect(). In production that dispatches to tokio. In simulation it dispatches to the simulator. The application code is identical, down to the compiler output for the generic function.
Why Not Mocks
A mock framework like mockall records expectations: “this method is called with these arguments and returns this value”. A mock tests that your code calls the right method in the right order. It does not test what happens when the network does something unexpected.
A mock for TCP says: “when connect("10.0.1.1:9000") is called, return Ok(stream)”. That says nothing about a connection that takes 3 seconds, a connection that succeeds and then resets on the first read, or a peer that rebooted between two requests and lost its state.
Providers are full implementations, not recorded expectations. The simulated network provider keeps connection state, buffers bytes, injects delays, models TCP half-close, and drops a connection on partition. It is a complete in-memory TCP. When your code connects through it, it gets a real (simulated) session with real (simulated) failure modes.
That fidelity includes asynchronous establishment. Simulated bind, connect and accept do not hand back a ready socket. They park the calling future, schedule a network operation with a delay, and wake the future when the delay expires. Storage follows the same contract for read_at, write_at, sync_all and set_len: each returns Pending and completes through a scheduled storage event. A provider is an adapter into a stateful engine, not a shortcut around the scheduler.
Oxide’s codebases show a fidelity spectrum: no-op fakes that return Ok(()), in-memory implementations that hold real data without real I/O, and full protocol simulation. Providers sit at the top of that spectrum, because a simulation must hit the edge cases production hits.
Never More Permissive
A simulated provider has one more duty: it must never accept what the production provider refuses. If the simulator accepted an open that a real filesystem rejects, simulated code would come to depend on it, and the run would stay green until it reached a real disk.
Two examples from the storage provider. OpenOptions::validate applies std::fs::OpenOptions’ own rules on both backends: truncate, create and create_new need write or append, and append with truncate contradict each other. The simulator refuses those with InvalidInput before it touches a file. And DirectIo::Required opens an existing file. A Required open that would have to create the file is refused by both backends. crates/moonpool-sim/tests/storage/parity.rs runs the same scenarios through TokioStorageProvider and SimStorageProvider and asserts that they answer alike. The simulated list_dir is checked against tokio’s for the same tree.
The Pattern
The recipe is short. Oxide’s teams converged on it independently, with almost no mock-framework use:
- Define a trait for the external dependency.
- Implement it for production with real tokio calls.
- Implement it for simulation, deterministic and controllable.
- Inject it through generics: compile-time dispatch, zero runtime overhead.
// Step 1: the trait (native async fn in trait, no #[async_trait])
pub trait TimeProvider: Clone + Send + Sync + 'static {
fn sleep(&self, duration: Duration) -> impl Future<Output = Result<(), TimeError>> + Send;
fn now(&self) -> Duration;
// ...
}
// Step 2: production
impl TimeProvider for TokioTimeProvider {
async fn sleep(&self, duration: Duration) -> Result<(), TimeError> {
tokio::time::sleep(duration).await; // a real wall-clock delay
Ok(())
}
// ...
}
// Step 3: simulation (moonpool-sim). sleep() schedules a timer event and
// parks the future; the scheduler advances logical time to the timer.
// Step 4: inject through generics
async fn heartbeat_loop<T: TimeProvider>(time: T) {
loop {
send_heartbeat().await;
time.sleep(Duration::from_secs(5)).await.ok();
}
}
If heartbeat_loop compiles with T: TimeProvider, it works with both TokioTimeProvider and SimTimeProvider. No runtime dispatch, no dynamic casts, no “forgot to wire the mock” surprise.
Why Static Dispatch
The provider traits are not object-safe, on purpose: the Clone supertrait, the generic random<T>(), the impl Future return types and the associated stream types all rule out dyn Providers. Simulation is test-only, so no production code ever needs to pick an implementation at runtime. Where generics hurt, erase at an application boundary with a narrow trait of your own. moonpool-rpc and moonpool-hyper do exactly this: they are generic over P: Providers all the way down and ship no providers of their own.
What You Get
Same code path. Your production code is your test code. No divergence, no conditional compilation.
Full behaviour, not call verification. A simulated network partitions, delays, reorders and resets. A simulated disk tears writes, corrupts sectors and lies about syncs. A mock records calls.
Resource ownership stays local. The network and storage engines own their state, pending results, faults and wakers. SimWorld coordinates their effects through one scheduler. Cancellation and shutdown are explicit: a crash completes pending work with errors, and a missing result is never mistaken for success.
A compile-time boundary. If you call tokio directly, the code still compiles and runs, but it is non-deterministic. Moonpool’s conventions, enforced in review, catch these: no tokio::time::sleep(), no tokio::spawn(), no tokio::net::*, no tokio::select!. Always go through the provider. Sim Compatibility Checklist is the full list.
The Six Providers
- TimeProvider
- TaskProvider
- NetworkProvider
- RandomProvider
- StorageProvider and StorageFile
- SystemProvider
- The Providers Bundle
- Local Providers
- Resolver
- select!
Moonpool puts every interaction between your code and the outside world behind six provider traits: TimeProvider, TaskProvider, NetworkProvider, RandomProvider, StorageProvider and SystemProvider. Each covers one category of I/O. Together they form a complete boundary around your application. A seventh trait, Resolver, sits outside the bundle for code that resolves names. The select! macro completes the set.
Every provider trait is Clone + Send + Sync + 'static. The simulation runs on one thread, but the types are Send-bounded so your code stays ordinary: Arc<RwLock<…>>, DashMap and Send task spawning all work. The async methods use native async-fn-in-trait, declared as -> impl Future<…> + Send so the bound propagates. There is no #[async_trait] and no ?Send in the provider layer.
TimeProvider
Every timeout, backoff, heartbeat and lease check goes through TimeProvider.
pub trait TimeProvider: Clone + Send + Sync + 'static {
fn sleep(&self, duration: Duration) -> impl Future<Output = Result<(), TimeError>> + Send;
fn now(&self) -> Duration;
fn timer(&self) -> Duration { self.now() }
fn timeout<F, T>(&self, duration: Duration, future: F)
-> impl Future<Output = Result<T, TimeError>> + Send
where F: Future<Output = T> + Send, T: Send;
}
The now() and timer() split comes from FoundationDB’s sim2. now() is the exact clock. timer() is a default method that returns now(), which is what production keeps. The simulation overrides it: timer() can run up to clock_drift_max (default 100 ms) ahead of now(). Use now() for scheduling. Use timer() for lease expiry and heartbeat deadlines, so clock skew between processes is part of every seed. sleep returns TimeError::Shutdown when the simulation is gone, and timeout returns TimeError::Elapsed.
Production: TokioTimeProvider delegates to tokio::time::sleep and tokio::time::timeout. now() is the elapsed time since the provider was created.
Simulation: SimTimeProvider::sleep schedules a timer in the Scheduler<Event>. When no task is runnable, the simulator pops the next event and jumps the clock to it. Hours of cluster time pass in seconds. timeout is a select! { biased; … } over the future and a timer, so the event order decides the winner and the race draws nothing.
TaskProvider
pub trait TaskProvider: Clone + Send + Sync + 'static {
type JoinHandle: Future<Output = Result<(), JoinError>> + Detach + Send + 'static;
fn spawn_task<F>(&self, name: &str, future: F) -> Self::JoinHandle
where F: Future<Output = ()> + Send + 'static;
fn yield_now(&self) -> impl Future<Output = ()> + Send;
}
Spawned futures are Send + 'static, as tokio::spawn requires. The handle resolves with Err(JoinError::Cancelled) or Err(JoinError::Panicked) when the task did not complete normally. Detach::detach() is explicit fire-and-forget: dropping the handle does the same, but detach() states the intent and satisfies the must_use lint.
Production: TokioTaskProvider calls tokio::spawn. The name feeds two tracing::trace! events, at start and at completion, and nothing else.
Simulation: SimTaskProvider spawns onto the deterministic executor, which stores the name and traces every poll under it. A process’s provider carries the scope of its boot: when the process is killed, every task spawned through it, and every task those spawned, is dropped. A workload’s provider has no scope, because a workload survives everything. A spawned task runs inside the span current at spawn time, so its tracing events keep their process or workload attribution.
NetworkProvider
pub trait NetworkProvider: Clone + Send + Sync + 'static {
type TcpStream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
type TcpListener: TcpListenerTrait<TcpStream = Self::TcpStream> + 'static;
fn bind(&self, addr: &str) -> impl Future<Output = io::Result<Self::TcpListener>> + Send;
fn connect(&self, addr: &str) -> impl Future<Output = io::Result<Self::TcpStream>> + Send;
}
pub trait TcpListenerTrait: Send + Sync + 'static {
type TcpStream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
fn accept(&self) -> impl Future<Output = io::Result<(Self::TcpStream, String)>> + Send;
fn local_addr(&self) -> io::Result<String>;
}
The stream types are futures::io::AsyncRead + AsyncWrite, the runtime-agnostic traits, so one codec or framing layer works on both backends. bind, connect and accept behave like their tokio counterparts.
Production: TokioNetworkProvider wraps tokio::net through tokio_util::compat. Every connected and accepted stream has Nagle’s algorithm disabled (TCP_NODELAY, best-effort), as FoundationDB’s Net2 does, because request-reply protocols write a small frame and wait for the answer.
Simulation: NetworkSimulation owns listeners, connections, topology, faults, pending results and wakers. bind, connect and accept park until their scheduled latency expires. Established streams use in-memory buffers with deterministic delivery delays, TCP half-close and fault injection: connection drops, partitions, corruption. Binding port zero on a numeric address assigns a distinct dynamic port, and local_addr() returns it. A bare process IP is also a valid address. Dropping a stream with unread bytes resets the peer, as TCP does. AsyncWrite::close shuts down the write half only. Each listener has a bounded accept backlog, 128 by default, set per run with SimulationBuilder::accept_backlog_capacity. Network Faults lists the fault families.
RandomProvider
pub trait RandomProvider: Clone + Send + Sync + 'static {
fn random<T>(&self) -> T where StandardUniform: Distribution<T>;
fn random_range<T>(&self, range: Range<T>) -> T where T: SampleUniform + PartialOrd;
fn random_ratio(&self) -> f64 { self.random() }
fn random_bool(&self, probability: f64) -> bool { self.random_ratio() < probability }
}
Only random and random_range are required. The trait is fully synchronous: random numbers never need to suspend.
Production: TokioRandomProvider uses rand::rng(), thread-local OS entropy.
Simulation: SimRandomProvider draws from the one seeded ChaCha8Rng stream. Every call is counted, so it is part of the replay and of exploration recipes.
StorageProvider and StorageFile
pub trait StorageProvider: Clone + Send + Sync + 'static {
type File: StorageFile + 'static;
fn open(&self, path: &str, options: OpenOptions) -> impl Future<Output = io::Result<Self::File>> + Send;
fn exists(&self, path: &str) -> impl Future<Output = io::Result<bool>> + Send;
fn delete(&self, path: &str) -> impl Future<Output = io::Result<()>> + Send;
fn rename(&self, from: &str, to: &str) -> impl Future<Output = io::Result<()>> + Send;
fn create_dir_all(&self, path: &str) -> impl Future<Output = io::Result<()>> + Send;
fn list_dir(&self, path: &str) -> impl Future<Output = io::Result<Vec<String>>> + Send;
fn sync_dir(&self, path: &str) -> impl Future<Output = io::Result<()>> + Send;
}
pub trait StorageFile: AsyncRead + AsyncWrite + AsyncSeek + Unpin + Send + Sync + 'static {
fn sync_all(&self) -> impl Future<Output = io::Result<()>> + Send;
fn sync_data(&self) -> impl Future<Output = io::Result<()>> + Send;
fn constraints(&self) -> IoConstraints;
fn is_direct_io(&self) -> bool;
fn read_at(&self, offset: u64, buf: &mut [u8]) -> impl Future<Output = io::Result<usize>> + Send;
fn write_at(&self, offset: u64, buf: &[u8]) -> impl Future<Output = io::Result<usize>> + Send;
fn write_at_durable(&self, offset: u64, buf: &[u8]) -> impl Future<Output = io::Result<usize>> + Send {
// default: write_at, then sync_data
}
// The zero-copy forms: the buffer moves in and comes back with the count.
fn read_at_aligned(&self, offset: u64, buf: AlignedBuf) -> impl Future<Output = io::Result<(usize, AlignedBuf)>> + Send {
// default: read_at on the buffer's bytes
}
fn write_at_aligned(&self, offset: u64, buf: AlignedBuf) -> impl Future<Output = io::Result<(usize, AlignedBuf)>> + Send {
// default: write_at on the buffer's bytes
}
fn write_at_durable_aligned(&self, offset: u64, buf: AlignedBuf) -> impl Future<Output = io::Result<(usize, AlignedBuf)>> + Send {
// default: write_at_durable on the buffer's bytes
}
fn size(&self) -> impl Future<Output = io::Result<u64>> + Send;
fn set_len(&self, size: u64) -> impl Future<Output = io::Result<()>> + Send;
fn allocate(&self, len: u64) -> impl Future<Output = io::Result<()>> + Send;
}
The split is the rule. The provider owns the namespace: open, exists, delete, rename, directory creation, listing and sync_dir. The file owns already-open bytes. A delete or a rename over a name removes the name and nothing else. A file opened before it keeps reading and writing the same bytes, as on Unix, until the last name and the last handle are both gone. The namespace is per process: two processes that open data.db open two files on two disks.
Three distinctions the trait keeps apart, because production keeps them apart:
- A completed write is visible, not durable. Only
sync_allorsync_datamakes it survive a crash.write_at_durableis the one write that is durable on return, and only for its own bytes: on Linux the Tokio backend issues onepwritev2(RWF_DSYNC)(a FUA write on a direct-I/O file whose blocks are allocated), which flushes nothing outside its range. The default, and the simulator, runwrite_atthensync_data. - File durability is not directory-entry durability. After
open(create),writeandsync_all, a crash can still leave no name pointing at the file.sync_diron the parent is the call that makes the name durable, as SQLite and PostgreSQL do after a create, delete or rename. set_lengrows the readable range. It does not allocate, initialize or make the length durable.allocateis the call that reserves space:fallocate(2)mode 0 on Linux, growing the file to at leastlen. The range reads as zeros, a full disk fails there and not on a later write, and the length is still durable only aftersync_all. The simulator already counts a grown range against the disk’s capacity, so there it is the same as growing.
OpenOptions mirrors std::fs::OpenOptions (read, write, create, create_new, truncate, append) and adds direct_io(DirectIo::{Disabled, Optional, Required}). Direct I/O is an option on opening an ordinary file, not a second provider: there is no BlockProvider. A direct-I/O file reports its alignment through constraints() and refuses the stream API with stream_io_unsupported(), because a shared cursor cannot honour an alignment. read_at and write_at are positioned, take &self, and may transfer fewer bytes than asked. Each positioned transfer also has an owned form over an AlignedBuf (read_at_aligned, write_at_aligned, write_at_durable_aligned): the same transfer and contract, with the buffer moved in and handed back beside the count (dropped on error). It exists for zero copy. A backend that runs I/O on another thread cannot move a borrowed slice there, so the borrowed form pays a copy into its own aligned buffer; the owned form moves the caller’s buffer instead. A caller that already holds its bytes in an AlignedBuf uses it, any other caller keeps the borrowed form, and neither is a different kind of file. BlockFile<F> wraps one open file to add block arithmetic and the loops that turn partial transfers into whole ones. It holds no path and opens nothing. LayoutRegion lets a format describe where its bytes live, so a fault injector can aim at them. Storage Faults and Aiming Storage Faults continue there.
Production: TokioStorageProvider wraps tokio::fs. Positioned I/O runs on the blocking pool, and O_DIRECT comes from libc. The owned transfers move the caller’s AlignedBuf into the blocking task and back, so pwrite, pwritev2 and pread reach the caller’s own memory; the borrowed ones copy through a bounce buffer, as a slice cannot cross into a 'static task.
Simulation: the owned transfers keep the trait’s defaults, the borrowed transfer on the buffer’s bytes, so every fault applies to them unchanged. StorageEngine keeps one in-memory image per file. A positioned write is visible to a stream read, and a block write to both. Open handles have independent cursors and pending operations. Every operation returns Pending and completes through a scheduled event, with an exact result, so a crash or shutdown completes pending work with errors instead of silence. Each SimStorageProvider is scoped to a process IP, which selects that process’s disk configuration and degradation episodes. The fault model follows TigerBeetle and FoundationDB: read and write corruption, EIO, the barrier-bounded crash model, misdirected I/O, short transfers, sync lies, unsynced directory-entry loss, and IOPS and bandwidth timing.
SystemProvider
How busy is this process? FoundationDB reads cumulative counters every few seconds: CPU seconds from getrusage, machine CPU from /proc/stat, the disk’s busy time from /proc/diskstats. Two readings and a division give cpu.usage_cores and disk.busy. SystemProvider is that reading:
pub trait SystemProvider: Clone + Send + Sync + 'static {
// Cumulative counters now. `data_dir` selects the disk device.
fn sample(&self, data_dir: &str) -> io::Result<SystemSample>;
}
A SystemSample carries the instant at, process_cpu, the usable cores, machine_busy and machine_total, the run loop’s busy time, and the DiskCounters of the device that holds data_dir: io_ticks (time with at least one I/O in flight), in_flight, completed reads and writes, their bytes and their times. space is the DiskSpace of the filesystem that holds data_dir (statvfs: total_bytes and the free_bytes an unprivileged writer may use), or, in simulation, of a disk given a capacity. The provider does no arithmetic. Every counter only grows, so a counter that goes back tells the caller the window is not valid.
Production: TokioSystemProvider reads getrusage(RUSAGE_SELF), /proc/stat and the /proc/diskstats line whose major and minor numbers match stat(data_dir). On btrfs, overlayfs and some containers no line matches, so disk is None. The run loop’s busy time is the busiest tokio worker’s. Remember that io_ticks is iostat’s %util: on flash and RAID it reaches 100% long before the device runs out of throughput.
Simulation: FoundationDB switches its system monitor off in simulation. Moonpool does the opposite. StorageEngine counts, per process, the time its disk has at least one operation in flight, parked operations on a failed disk included, so a stall or throttle episode makes io_ticks rise. SimWorld::disk_counters(ip) returns the same numbers, so a harness can check a process’s report against ground truth. With the CPU model on, every poll of the process’s tasks costs simulated time on its one core, and process_cpu, machine_busy and run_loop_busy are that core’s busy time. With the model off they stay at zero. The world remembers the last 64 samples per process (SYSTEM_SAMPLES_KEPT), readable with ctx.system_samples(ip). A sample draws nothing from the seed.
The Providers Bundle
pub trait Providers: Clone + Send + Sync + 'static {
type Network: NetworkProvider;
type Time: TimeProvider;
type Task: TaskProvider;
type Random: RandomProvider;
type Storage: StorageProvider;
type System: SystemProvider;
fn network(&self) -> &Self::Network;
fn time(&self) -> &Self::Time;
fn task(&self) -> &Self::Task;
fn random(&self) -> &Self::Random;
fn storage(&self) -> &Self::Storage;
fn system(&self) -> &Self::System;
}
TokioProviders::new() bundles the six production implementations. SimProviders::new(sim, ip) bundles the six simulation implementations, scoped to one process IP. Your application code sees P: Providers and nothing else.
Local Providers
A thread-per-core runtime (compio, monoio) pins every task, file and socket to one thread. Its files hold an Rc to their descriptor and its futures hold the thread’s proactor, so none of them is Send, and it cannot implement the traits above. Each Send trait has a local form: the same methods and the same contract, with no Send or Sync bound on the trait, its associated types or its futures.
| Local trait | Send trait | Blanket impl |
|---|---|---|
LocalStorageProvider, LocalStorageFile | StorageProvider, StorageFile | yes |
LocalNetworkProvider, LocalTcpListener | NetworkProvider, TcpListenerTrait | yes |
LocalTimeProvider | TimeProvider | yes (timeout takes a !Send future) |
LocalTaskProvider (spawn_local_task) | TaskProvider | no |
LocalProviders (no task slot) | Providers | yes |
pub trait LocalStorageFile: AsyncRead + AsyncWrite + AsyncSeek + Unpin + 'static {
fn read_at(&self, offset: u64, buf: &mut [u8]) -> impl Future<Output = io::Result<usize>>;
// ... every StorageFile method, no `+ Send`
}
impl<F: StorageFile> LocalStorageFile for F { /* forwards every method */ }
Every Send backend implements the local traits through the blanket impls, so the Tokio and simulated providers get them for free. A thread-per-core backend implements only the local traits. Code written once against the local traits runs on every backend, and when the backend is Send the futures it builds are Send too: the auto traits of the concrete future leak through the generic code, so it can still go to tokio::spawn.
Tasks have no blanket impl in either direction. A TaskProvider cannot spawn a !Send future, and a local provider pins its tasks to one thread. A backend that can do both implements both. CompioTaskProvider is the production local one. SimTaskProvider implements both traits, because the executor runs every task on one thread anyway: a local task inherits the owner and the boot scope, dies with its process, reports its panics, and is scheduled by the same seeded executor. So local code runs under simulation unchanged, Rc<RefCell<..>> across awaits included. A Process or Workload future stays Send: it spawns the local code with ctx.task().spawn_local_task(..) and awaits or detaches the handle, which is Send (crates/moonpool-sim/tests/local_workload.rs). ctx.run_local(name, |ctx| async move { .. }) does this for a body that returns an answer: it runs the body as a local task of the process, with its own clone of the context, and returns the answer over a channel.
LocalProviders leaves the task slot out so that it can be a blanket impl over Providers: every existing bundle is a LocalProviders. Code that spawns local tasks takes a LocalTaskProvider beside the bundle, or a ThreadProviders bundle: a LocalProviders with a local task slot (local_task()). SimProviders is one (through a blanket impl over every Providers whose task provider is also local) and CompioProviders is one. TokioProviders is not. Random and system keep their Send traits: their methods are synchronous, so a thread-per-core runtime backs them with the same types Tokio uses.
The compio backend. The compio-* features (umbrella compio-providers, off by default) add a production backend on compio, a completion-based, thread-per-core runtime: io_uring on Linux (polling where the kernel refuses io_uring), IOCP on Windows. It implements only the local traits:
| Feature | Type | Notes |
|---|---|---|
compio-task | CompioTaskProvider | compio::runtime::spawn; the handle detaches on drop, as Tokio’s does |
compio-time | CompioTimeProvider | compio’s timer; timer() equals now() |
compio-net | CompioNetworkProvider | compio TCP behind compio’s AsyncStream adapter (futures::io), TCP_NODELAY set |
compio-fs | CompioStorageProvider | each positioned transfer, sync and size query is one driver operation; the *_aligned forms hand the caller’s AlignedBuf to the kernel |
compio-system | CompioSystemProvider | the Tokio provider’s OS counters; run_loop_busy is the CPU time of the calling thread (sample from the runtime’s thread) |
compio-providers | CompioProviders | a LocalProviders and ThreadProviders bundle (random: the thread-local RNG) |
Every call needs a compio runtime on the current thread. Storage opens follow the Tokio backend’s direct-I/O policy, from the same code: DirectIo::Optional upgrades to O_DIRECT where the filesystem allows it, DirectIo::Required fails rather than fall back and never creates a file, and a direct file advertises the alignment the kernel reports. The owned *_aligned transfers hand the caller’s AlignedBuf straight to io_uring. On Linux an append file’s stream writes go through an O_APPEND descriptor, so they are atomic against other writers, and positioned I/O uses a second descriptor of the same file without the flag. Elsewhere pwrite honours its offset under O_APPEND, so the stream appends at the size it reads first, which is not atomic against other writers.
Libraries on the local traits. moonpool-journal and moonpool-rpc are written against the local traits, so the same code runs on Tokio, on compio and under simulation. The journal takes a LocalStorageProvider. The RPC runtime takes an RpcProviders bundle: a LocalProviders whose values (not its futures or streams) are Send + Sync, which every bundle in moonpool is. Over a Send backend their futures stay Send, and tests/send.rs in each crate checks this. On compio, poll the RPC driver and its callers on one thread, for example with compio::runtime::spawn(driver.run()). Each crate has a compio-providers feature that runs its compio tests and its backends example, which compares the two backends (see docs/benchmarks/).
io_uring in containers. Docker’s default seccomp profile blocks the io_uring_setup, io_uring_enter and io_uring_register syscalls, and some hosts turn io_uring off with the kernel.io_uring_disabled sysctl. There, compio falls back to its polling driver: the code still works, but without the batched submissions that make the backend worth it. To get io_uring in a container, run it with a seccomp profile that allows those three syscalls.
Method-name ambiguity. A concrete Tokio or sim type has both traits’ methods under the same names. With both traits in scope, file.read_at(..) is ambiguous: import one trait per module, or call through the trait (LocalStorageFile::read_at(&file, ..)). Generic code bounded by one trait is not affected. The local traits are not in the prelude.
Resolver
Name resolution is not in the bundle. Most code never resolves a name, and code that does picks its resolver next to its providers.
pub trait Resolver: Clone + Send + Sync + 'static {
fn resolve(&self, target: &str) -> impl Future<Output = io::Result<Vec<SocketAddr>>> + Send;
}
The target is the same host:port string connect takes. A numeric target resolves to itself. An Ok answer is never empty: a name with no address is a NotFound error, so a caller can tell a failed lookup from a failed connection. Caching and time-to-live belong to the caller.
Production: TokioResolver (feature tokio-net) calls tokio::net::lookup_host. CompioResolver (feature compio-net) runs the operating system’s lookup on a helper thread and answers over a channel, so its future is Send and runs on any executor.
Simulation: ScriptedResolver answers from a shared table that a fault injector or workload edits while the run goes. Answers are immediate and draw no randomness.
select!
moonpool_core::select!, re-exported as moonpool_sim::select! and moonpool::select!, is tokio’s own select! macro with one change. tokio’s fair mode polls branches starting at a random index drawn from its thread-local RNG, which is seeded from process entropy when there is no tokio runtime. That is a hole in the replay. Moonpool’s macro calls tokio’s internal expansion with the start offset drawn from select_support::select_offset, which moonpool-sim points at the seeded stream for each iteration. Grammar, limits, if preconditions and the 64-branch cap are tokio’s, byte for byte.
select! { biased; … } forwards verbatim to tokio: source-order polling is already deterministic. Use biased; when branches have a natural priority, such as a shutdown or timeout guard. Use the default form when branches are peers racing for the same wake, so the seeds explore both winners.
Which macro you get is decided by cargo feature unification. With core’s select feature alone, the re-export is tokio’s macro verbatim. The moment moonpool-sim enters the build graph, even as a dev-dependency, its deterministic-select feature replaces it for every crate in that graph. Same Code in Production and Simulation explains what that means for a crate’s manifest.
Same Code in Production and Simulation
- No Sim* Wrappers
- Three Mechanisms
- Inert in Production
- Keeping Sim Out of the Release Binary
- The select! Asymmetry
- The Checklist
One rule sits under every other design choice in moonpool: the code you ship is the code you simulate. Not a copy of it. Not a wrapper around it. The same compiled function, with a different Providers type plugged in. This chapter states the rule, shows the three mechanisms that make it hold, and shows how a crate keeps the simulator out of its release binary.
No Sim* Wrappers
The tempting shortcut is a test-only twin of each component: a SimMachine next to Machine, a SimDisk next to Disk, a #[cfg(test)] hook that lets the test reach inside. Each twin is a place where the tested behaviour and the shipped behaviour drift apart. After a year, the twin tests itself.
Moonpool has none of these in shipped code. There is no SimMachine, no SimDisk, no test-only hook inside a journal or an RPC runtime. The only Sim* types in the workspace are the providers themselves: SimTimeProvider, SimNetworkProvider, SimStorageProvider and their siblings in moonpool-sim. They implement the same traits TokioTimeProvider and TokioStorageProvider implement. Everything above the trait line is one implementation.
The same rule applies to your code. A process in a simulation is your server, built by a factory, not a simplified model of it. moonpool-journal is a worked example: one Journal<P: LocalStorageProvider> runs as Journal<TokioStorageProvider> in its accept_log example, as Journal<CompioStorageProvider> on a thread-per-core runtime, and as Journal<SimStorageProvider> in its tests, and the crash tests exercise the exact commit path production uses.
Three Mechanisms
Three things let one implementation serve both worlds.
Providers carry the I/O. The code depends on P: Providers, never on tokio. In production main builds TokioProviders::new(). In a simulation the runner hands a SimProviders to each process and workload through ctx.providers(). The Provider Pattern covers this. moonpool-rpc and moonpool-hyper follow the same rule: both are generic over P: Providers, ship no providers, and never depend on moonpool-sim.
The code names moments, the simulator strikes. Some faults are best placed by the code that knows the moment. A journal knows the instant where its entries are written but not synced. The code names that moment with hint! from moonpool-buggify:
segment.write(batch.entry_at, &batch.entries).await?;
// A power loss here tears entries no record points at yet.
moonpool_buggify::hint!("journal commit: entries written, not synced", COMMIT_HINT_PROB).await;
segment.sync().await?;
The code never names a fault. It says “this is interesting”. In production the point draws nothing, asks no one, and the future resolves at once. In a simulation the point is a disruptive buggify site: activated once per run, firing at POINT_PROB (5%) per call or at the rate the site gives. When it fires, moonpool-sim’s HintSink finds the calling process through the owner of the task being polled and asks the first attrition regime whose victim filter admits that process. The regime’s max_dead budget, reboot kinds and recovery delays apply, as for its own timed reboots. The harness can add a HintVeto for a budget the simulator cannot see. A seed whose swarm drew the never-reboot regime never reboots on a hint. hint!: Naming Fault Sites covers the vetoes.
The code’s own choices use buggify. When the code itself picks a rare but legal behaviour, it uses the inline macros: buggify!() fires at 25% while its site is active, buggify_with_prob!(p) at a site rate, buggify_fault_with_prob!(p) for a site that makes an operation fail and goes silent in the recovery tail, buggify_named!("label", p) for a site a harness can switch per seed with set_activation, and buggify_pick! and buggify_range! for a site that overrides a choice or stretches a value. moonpool-rpc uses these inside its shipped transport: a session cut right after a frame, a request refused as Overloaded, a deferred stream acknowledgement. cpu_charge(duration) is the same idea for CPU time: shipped code reports that it did a known amount of work, and a simulator with a CPU model makes that work take time.
Inert in Production
All of this costs nothing outside a simulation. moonpool-buggify is a pure-std crate with zero dependencies. Its state is thread-local and disabled by default. Outside a simulation:
- every
buggify!,buggify_with_prob!,buggify_fault_with_prob!andbuggify_named!site evaluates tofalsewith no draw and no side effect buggify_pick!andbuggify_range!returnNonehint!returns a future that resolves at once, without asking any sinkcpu_chargedoes one thread-local read and returnsis_simulated()returnsfalse, so code can tilt a rate or a check in simulation without changing an outcome a caller can observe
A simulation turns the crate on for one run: moonpool-sim installs its seeded draw with set_random_source, calls buggify_init(0.5) so each site is active with 50% probability, installs its hint and CPU sinks, and resets everything when the run ends. The same thread-local state serves moonpool_sim::buggify! and moonpool_buggify::buggify!, so a library and the harness that tests it share one set of activation decisions.
Keeping Sim Out of the Release Binary
Determinism costs nothing at runtime in production, and the build must cost nothing either. Three manifest rules keep the simulator, the explorer and libc/mmap out of a release.
A library depends on the traits and the macros only. moonpool-journal’s manifest is the pattern:
[dependencies]
moonpool-core = { version = "0.8.0", default-features = false }
moonpool-buggify = "0.8.0"
[dev-dependencies]
moonpool-core = { version = "0.8.0", default-features = false, features = ["tokio-fs"] }
moonpool-sim = "0.8.0"
default-features = false on moonpool-core drops the tokio providers and the select! passthrough, so the library compiles with no tokio at all. The simulator enters only as a dev-dependency. The tests get SimStorageProvider; the release gets nothing.
A binary enables the production providers it needs. moonpool-core’s features are granular: tokio-task, tokio-time, tokio-net, tokio-fs, tokio-random, tokio-system, with tokio-providers as the umbrella that gives TokioProviders. A consumer that only needs files enables tokio-fs. With every tokio feature off, moonpool-core is wasm-clean.
The facade has a lean configuration. The moonpool crate’s default features are ["sim", "tokio"], the full framework. A production build opts out:
moonpool = { version = "0.8.0", default-features = false, features = ["tokio"] }
tokio enables core’s tokio-providers and select, so TokioProviders and moonpool::select! are there. sim is off, so moonpool-sim and moonpool-explorer are absent from the dependency tree. hyper, prometheus, rpc, rpc-derive, rpc-tls and rpc-jwt are opt-in on top of either configuration.
The select! Asymmetry
One thing to know when you read a lock file. moonpool_core::select! is tokio’s macro verbatim with core’s select feature, and tokio’s expansion with a seeded offset with core’s deterministic-select feature. moonpool-sim enables deterministic-select. Cargo unifies features across a build graph, so the moment moonpool-sim is in the graph, even as a dev-dependency of one crate, every crate in that graph gets the seeded macro, including your library compiled for tests. Both forms are tokio’s expansion with identical grammar. The only change is where the start offset comes from, and outside a simulation that offset is entropy-seeded, as tokio’s is. A release build without moonpool-sim keeps tokio’s macro byte for byte.
The Checklist
When you review a change against this rule, ask four questions:
- Does the code reach tokio,
std::time,randor the filesystem without a provider? Move it behindP: Providers. - Does a test reach inside the component through a hook that production does not use? Replace the hook with a
hint!or abuggify_named!site the simulator can drive. - Does the component have a
Sim*twin? Delete the twin and run the real one underSimProviders. - Does the library’s manifest depend on moonpool-sim outside
[dev-dependencies]? Move it.
Using Providers in Production shows the production side of the wiring, and Migrating Existing Code to Providers walks through an existing service.
System Under Test vs Test Driver
- Two Perspectives on One System
- Process: The Thing You Build
- Workload: The Thing That Tests
- Different Lifecycles
- Addresses
- Why Not One Trait?
- What Comes Next
Every moonpool simulation has two roles. Understanding this split is the one concept to have before you write a line of simulation code.
Two Perspectives on One System
Think about how you test a web server. You have the server: handling requests, managing connections, storing data. And you have something else: a client, a load generator, a harness that sends requests and checks the answers.
These two have different jobs. The server is the system. The driver exercises the system.
Moonpool makes the split explicit with two traits: Process and Workload.
Process: The Thing You Build
A Process is your server, your node, your participant in the distributed system. It is the system under test. The code inside a Process is the code you ship. There is no simulation-only copy of it.
Processes have a hard life in simulation:
- They crash. The simulation kills them without warning, at a timed reboot, at a correlated outage, or at a
hint!the process named itself. - They reboot. After a kill, the runner calls your factory for a fresh instance.
- They lose state. Every in-memory field is gone on reboot. Only what reached the simulated disk survives, and only the part that was synced.
- They get partitioned, delayed and slowed. Connections break, packets arrive late, a gray failure makes one machine’s CPU, disk or network crawl.
This is the point. The simulation throws chaos at your server and watches what breaks.
In FoundationDB’s simulation the equivalent is the fdbserver process: the same code as production, inside a world where the simulator decides when clocks advance, when packets arrive and when machines die.
Workload: The Thing That Tests
A Workload is your test driver. It lives outside the system under test. It sends requests, observes answers, and judges whether the system behaved.
Workloads have an easy life:
- They never crash. The simulation does not reboot workloads, and a
crash_selffrom a workload is an error. - They survive everything. Processes come and go. Workloads keep running.
- They see the whole picture. A workload knows every process IP, every group, every tag, and can read the simulator’s ground truth: a process’s disk counters, its system samples, its current slowness.
- They judge correctness. After the run, each workload’s
check()validates the final state, and its verdict counts.
In FoundationDB the equivalent is tester.actor.cpp. The tester knows the cluster, drives operations against it, and validates the result.
Different Lifecycles
The two lifecycles are different in shape.
A Process lifecycle is a loop: the factory builds one, it runs, it is killed, the factory builds another, it runs, it is killed. Each incarnation starts with empty memory. Its ctx.providers() is fresh each boot, and every task it spawned dies with it.
A Workload lifecycle is a line: setup() once at the start, run() for the test, check() at the end. One life from start to finish.
The key point: a Process does not know when it will die, and a Workload does not care when Processes die. The Process does its job correctly or it does not. The Workload keeps sending requests and tracking what happened.
Addresses
The runner gives each role its own IP range, so a log line tells you at a glance who speaks:
| Role | Addresses |
|---|---|
| First process group | 10.0.1.1, 10.0.1.2, … |
| Second process group | 10.0.2.1, 10.0.2.2, … |
| Workloads | 10.0.0.1, 10.0.0.2, … |
Each .processes() or .cluster() call registers one group, named after its Process::name(), with its own factory and its own per-seed count. Process Groups, Tags and Topology covers multi-role systems.
Why Not One Trait?
Why not “participants” that do both? Because mixing the two concerns makes a mess. If server code also tracks test state, it cannot be rebooted cleanly. If driver code also runs server logic, it cannot survive a crash.
The split also matches production. You deploy servers. You run integration tests against them. Different code, different lifecycles, different concerns. And it matches the rule in Same Code in Production and Simulation: the Process is the shipped code, and the Workload is the only place where test logic lives.
What Comes Next
The next two chapters cover each trait in detail: Process with its factory, its reboots and its context, then Workload with its three phases and its operation alphabet. After that, Part III builds a complete simulation from scratch.
Process: Your Server
- The Process Trait
- The Factory
- State and Reboots
- The Context
- Tags for Roles
- Three Kinds of Reboot
- Crashing Yourself
- A Concrete Example
A Process is the system under test. It is the code you ship, running inside a simulation where the framework controls time, the network, the disk and failure.
The Process Trait
The trait is minimal by design:
#[async_trait]
pub trait Process: Send + Sync + 'static {
fn name(&self) -> &str;
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()>;
}
Two methods. name() identifies the process type, and it names the process group the builder registers. run() holds your server logic. Every Process is Send + Sync + 'static, so it composes with Arc<RwLock<…>> and DashMap like ordinary tokio code. The runtime is still one thread.
When run() returns Ok(()) or an Err, the process has exited on its own. When the simulation kills the process, the future is cancelled and run() never returns. So is every task the process spawned through ctx.task().spawn_task(), however deep: a crash ends the whole process, not only its root future, so a background worker never survives to run beside the rebooted process. When run() panics, the seed fails. The panic is caught and recorded against the process’s IP, and the iteration is reported as failed even if every workload returned Ok.
The Factory
You never construct a Process once. You give the builder a factory:
SimulationBuilder::new()
.processes(3, || Box::new(MyServer::new()))
Why a factory? Reboots. When the simulation kills a process and restarts it, the runner calls the factory for a new instance. Each boot starts from a clean slate, as a restarted server does. Three processes with two reboots each means nine factory calls.
The count can be a range. .processes(3..=7, …) draws a count per seed, so cluster size is one more thing the seeds explore.
State and Reboots
This is the rule to internalize: all in-memory state is lost on reboot.
If your process keeps a BTreeMap of client sessions in a field, that map is gone after a reboot. The new instance starts empty. Only data written to the simulated disk survives, and of that, only what was synced. A write that completed but was not synced resolves by crash physics: kept, lost, or torn, sector by sector. Storage Faults describes the crash model.
This matches reality. A crashed server does not recover its heap. It reads persistent state from disk and rebuilds.
To carry a fact across boots for the test’s sake, use ctx.state(), the shared state handle. It belongs to the run, not to the process, so a boot counter published there is visible to the next boot and to every workload and invariant. Use it for test bookkeeping only. Shipped code must not depend on it.
The Context
run() receives a SimContext. The parts a process uses:
| Call | What it gives |
|---|---|
ctx.my_ip() | this process’s address, as a &str |
ctx.providers() | the SimProviders bundle, for code generic over P: Providers |
ctx.network(), ctx.time(), ctx.task(), ctx.random(), ctx.storage() | the individual providers |
ctx.providers().system() | the system provider: CPU and disk counters |
ctx.topology() | process IPs, groups, tags, locality |
ctx.client_id(), ctx.client_count() | this process’s index within its group, and the group size |
ctx.shutdown() | the cancellation token a graceful reboot fires |
ctx.state() | the run’s shared state |
ctx.metrics::<S>() | this node’s metrics source, when a metrics_factory is set |
ctx.crash_self(kind, delay), ctx.self_crash() | crash this process now, from inside it |
The providers are fresh each boot. Bind listeners, open files and connect to peers inside run(), not in the factory.
Tags for Roles
Many systems need nodes with different roles: leader and follower, primary and secondary, several data centres. Tags handle this:
SimulationBuilder::new()
.processes(5, || Box::new(MyNode::new()))
.tags(&[
("role", &["leader", "follower"]),
("dc", &["east", "west", "eu"]),
])?
tags() applies to the last registered group and returns a Result, because it is an error to call it before processes(). Tags distribute round-robin:
| Process | role | dc |
|---|---|---|
| 0 | leader | east |
| 1 | follower | west |
| 2 | leader | eu |
| 3 | follower | east |
| 4 | leader | west |
Read your tags from the topology:
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
match ctx.topology().my_tags().get("role") {
Some("leader") => run_leader(ctx).await,
Some("follower") => run_follower(ctx).await,
_ => Ok(()),
}
}
ctx.topology().ips_tagged("role", "leader") finds the others. For a second role with its own process type, register a second group instead. Process Groups, Tags and Topology covers both.
Three Kinds of Reboot
RebootKind has three variants:
Graceful. The runner cancels ctx.shutdown(). The process has a grace period to finish in-flight work and close connections. Send buffers drain, so peers see a clean FIN. If the process does not return in time, it is force-killed.
Crash. Instant death. The task is cancelled at the crash instant. Connections reset, so peers see ConnectionReset. Unsynced storage data may be lost.
CrashAndWipe. A crash that also deletes every file the process owns. This simulates a lost disk or a fresh node joining the cluster. The wipe is scoped to the process’s IP.
Handle a graceful shutdown by racing your work against the token. The biased; form polls the branches in order and draws nothing, which is right when one branch is a guard:
loop {
let result = moonpool_sim::select! {
biased;
r = listener.accept() => r,
() = ctx.shutdown().cancelled() => return Ok(()),
};
// ...
}
You do not handle a crash. There is nothing to handle. The simulation cancels your future and moves on.
Crashing Yourself
Sometimes a crash must land at an exact point of your own protocol: between a write and its sync, in the middle of an append. ctx.crash_self(RebootKind::Crash, Some(Duration::from_millis(10))) schedules the same force kill a fault injector would, one scheduler tick from now, and restarts the process after the delay. With None the process stays down until an injector restarts it. ctx.self_crash() returns a SelfCrash handle that is Clone + Send + Sync, for code that holds no context. A graceful kind is refused: a process shuts down gracefully by returning. This is a test instrument. Shipped code names the moment with hint! instead and lets the simulator decide, as Same Code in Production and Simulation explains.
A Concrete Example
An echo server as a Process, from moonpool’s own reboot tests:
use futures::io::{AsyncReadExt, AsyncWriteExt};
struct EchoProcess;
#[async_trait]
impl Process for EchoProcess {
fn name(&self) -> &'static str {
"echo"
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let listener = ctx.network().bind(ctx.my_ip()).await?;
loop {
let result = moonpool_sim::select! {
biased;
r = listener.accept() => r,
() = ctx.shutdown().cancelled() => return Ok(()),
};
match result {
Ok((mut stream, _peer)) => {
let mut buf = [0u8; 64];
if let Ok(n) = stream.read(&mut buf).await {
if n > 0 {
let _ = stream.write_all(&buf[..n]).await;
}
}
}
// Under chaos an accept can fail; the server keeps serving.
Err(e) => tracing::debug!("accept error: {e}"),
}
}
}
}
The patterns: ctx.network() for sockets, ctx.shutdown() for a graceful stop, moonpool_sim::select! for the race. Never call tokio directly. In a real service the body of run() would call a function generic over P: Providers with ctx.providers(), and main would call the same function with TokioProviders.
Workload: Your Test Driver
- The Workload Trait
- The Three Phases
- Workloads Survive Everything
- The Context
- The Operation Alphabet
- Multiple Instances
- What a Good Workload Looks Like
A Workload exercises the system under test and judges its correctness. Processes are the code you ship. Workloads are the code that finds your bugs.
The Workload Trait
#[async_trait]
pub trait Workload: Send + Sync + 'static {
fn name(&self) -> &str;
async fn setup(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
Ok(())
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()>;
async fn check(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
Ok(())
}
}
Four methods, two with defaults. The lifecycle is strict: setup(), then run(), then check(). Each phase has its own rules.
The Three Phases
Setup: Prepare the Ground
setup() runs sequentially across workloads. Workload A’s setup completes before workload B’s starts.
Use it to find your servers, open connections and build data structures. When run() starts, every workload is ready.
async fn setup(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let server = ctx
.topology()
.all_process_ips()
.first()
.cloned()
.ok_or_else(|| SimulationError::InvalidState("no servers".into()))?;
self.server = Some(server);
Ok(())
}
Do not hold a connection across setup if chaos is on. The server can reboot before run() starts. Connect inside run() and reconnect on error.
Run: Drive the System
run() is where the action is. All workloads run concurrently: several clients hitting the same servers, competing for resources, triggering races.
Send requests, observe responses, keep a reference model, and assert on every answer. The phase ends when every workload returns or when the runner cancels ctx.shutdown().
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
for _ in 0..self.operations {
if ctx.shutdown().is_cancelled() {
break;
}
match self.random_op(ctx.random()) {
Op::Write { key, value } => {
self.send_write(ctx, &key, &value).await?;
self.model.insert(key, value);
}
Op::Read { key } => {
let got = self.send_read(ctx, &key).await?;
let expected = self.model.get(&key).cloned();
assert_always!(got == expected, "read returns the last committed write");
}
}
}
Ok(())
}
Check: Judge the Outcome
check() runs sequentially after every run() has finished and the event queue has drained. The system is quiescent. No message is in flight, no timer is pending.
Use it for final validation. Did the conservation law hold? Did every acknowledged write survive the reboots? The verdict counts: a check() that returns Err or panics fails the seed, exactly as a failing run() does, even when every run() returned Ok.
async fn check(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
let total = self.model.values().sum::<i64>();
assert_always!(total == self.deposited - self.withdrawn, "money is conserved");
Ok(())
}
Workloads Survive Everything
This is the difference from processes. When the simulation kills a server, your workload keeps running. When a connection breaks, your workload reconnects. When the network partitions, your workload sees the errors and adapts.
Your workload remembers the whole run, across every reboot. That is how you verify recovery: the workload sent a write before the crash, the server rebooted, and the workload reads the value back.
A workload spawns tasks too, through ctx.task().spawn_task(). Those tasks carry no process scope, so they live as long as the workload does. A panic in a detached task is recorded against the workload and fails the seed unless the handle is awaited and the error handled.
The Context
Every phase receives a SimContext. The parts a workload uses:
| Call | What it gives |
|---|---|
ctx.my_ip() | this workload’s address, on 10.0.0.x |
ctx.client_id(), ctx.client_count() | this instance’s index and the instance count of its builder entry |
ctx.providers() | the SimProviders bundle, for code generic over P: Providers |
ctx.network(), ctx.time(), ctx.task(), ctx.random(), ctx.storage() | the individual providers |
ctx.topology() | all_process_ips(), ips_in_group(name), ips_tagged(key, value), locality |
ctx.peer(name), ctx.peers() | other workloads by name |
ctx.state() | shared state: publish(key, value) and get::<T>(key), read by invariants and other workloads |
ctx.shutdown() | the cancellation token the runner fires at the end of the run phase |
ctx.observability() | a TraceQuery over the captured timeline, for check() |
ctx.metrics_handle() | every node’s metrics sources, to read a server’s counters in check() |
ctx.disk_counters(ip) | the simulator’s ground-truth counters for a process’s disk |
ctx.system_samples(ip) | the last samples that process was handed by its SystemProvider |
ctx.slowness(ip) | the process’s current gray-failure factors, Slowness::HEALTHY when none |
The last three let a workload compare what a process reports about itself against what the simulator knows. A process that says “my disk is 90% busy” can be checked against ctx.disk_counters(ip). A workload that sees a slow answer can ask ctx.slowness(ip) whether the machine is in a gray failure, and assert that a healthy one answers fast. CPU Time and Gray Failures builds on this.
The Operation Alphabet
Strong workloads define an operation alphabet: the set of actions they can take. Deposit, withdraw, read, transfer, small delay. Each has a weight:
fn random_op(&self, random: &SimRandomProvider) -> Op {
match random.random_range(0..100) {
0..30 => Op::Deposit { .. },
30..50 => Op::Withdraw { .. },
50..70 => Op::Read { .. },
70..90 => Op::Transfer { .. },
_ => Op::SmallDelay,
}
}
The weights matter. Too many reads and you never test write conflicts. Too many writes and you never test read-after-crash. Cover normal operations, adversarial inputs, and delays that let background work run.
Then let the seeds vary the alphabet. With SimulationBuilder::swarm_operations(), each seed enables a random subset of up to 256 operation ids, and swarm_op_enabled(op_id) tells the workload which ones:
let enabled: Vec<Op> = ALPHABET
.iter()
.copied()
.filter(|op| swarm_op_enabled(op.id()))
.collect();
Why? Some bugs only appear when an operation is absent. A compaction that is never triggered, a read that never races a write. A single workload with a swarmed alphabet finds those across seeds. Many separate workloads, one per scenario, do not. Prefer one workload, many behaviours. Swarm Testing has the evidence, and Designing Workloads That Find Bugs has the patterns.
Multiple Instances
One client is rarely enough. workloads() builds several from a factory:
SimulationBuilder::new()
.workloads(WorkloadCount::Fixed(3), |i| Box::new(ClientWorkload::new(i)))
Each instance gets its own client_id() and its own IP. WorkloadCount::Random(1..6) draws a different count per seed from the simulation stream, so it stays deterministic per seed. The factory receives the index, and the workload is responsible for a unique name(), such as format!("client-{i}").
Factory workloads are also what check_determinism() and exploration need: an instance workload would carry its state from one timeline into the next.
What a Good Workload Looks Like
- Define an operation alphabet with weights, and swarm it.
- Keep a reference model of the expected state.
- Assert on every response with
assert_always!. - Mark interesting paths with
assert_sometimes!, so the run stops only when they fired. - Validate the final state in
check()against the model. - Publish facts through
ctx.state()and plaintracingevents, so invariants can check cross-workload properties.
Your First Simulation builds one from scratch.
Your First Simulation
Part II covered the theory: deterministic execution, providers, and the split between Process and Workload. Now we build a complete simulation and run it.
What We Are Building
Over the next five chapters we write a simulation test for a small key-value server. At the end you have:
- A Process: the key-value server. It accepts TCP connections and answers get and set requests. The simulator can crash it and reboot it.
- A Workload: the test driver. It sends random operations, keeps a reference model of what the server should contain, and checks every answer.
- Assertions inside both, so the report tells us what the run found and what it reached.
- A SimulationBuilder configuration that runs the pair under chaos for many seeds.
The server is deliberately small. The interesting part is not its logic. It is how the simulation wraps around it and finds bugs that unit tests never reach.
The Destination
This is the main we are working towards. Every call in it gets its own section in the next chapters.
use std::time::Duration;
use moonpool_sim::{
Attrition, AttritionScope, AttritionVictims, Chaos, ChaosMode, SimulationBuilder,
SimulationError,
};
fn main() -> Result<(), SimulationError> {
// WARN keeps a 100-seed campaign quiet; raise it when one seed misbehaves.
moonpool_sim::init_sim_tracing(tracing::Level::WARN);
let report = SimulationBuilder::new()
.processes(3, || Box::new(KvServer))
.workload(KvWorkload::new(200))
.enable_chaos([
Chaos::Network(ChaosMode::Swarm),
Chaos::Attrition {
config: Attrition {
max_dead: 1,
prob_graceful: 0.3,
prob_crash: 0.5,
prob_wipe: 0.2,
recovery_delay_ms: None,
grace_period_ms: None,
scope: AttritionScope::PerProcess,
victims: AttritionVictims::Any,
wipe_budget: None,
},
mode: ChaosMode::Swarm,
},
])
.chaos_duration(Duration::from_secs(30))
.set_iterations(100)
.run()?;
report.eprint();
if report.is_failure() {
std::process::exit(1);
}
Ok(())
}
Three servers, one client, network faults and process reboots for 30 simulated seconds, 100 seeds. run() returns the report. is_failure() is true when a seed failed, an always-assertion was violated, or the assertion table overflowed.
Prerequisites
The repository builds inside the Nix shell. Every cargo command runs through nix develop:
nix develop --command cargo build
Simulation binaries live in crates/moonpool-sim-examples/src/bin/sim/ and are managed by xtask:
cargo xtask sim list # what exists
cargo xtask sim run tonic # run binaries whose name matches "tonic"
cargo xtask sim run-all # run every binary
Your own simulation can be a plain #[test] or a binary. Both call the same builder. Running and Observing covers both routes and the report they print.
The Plan
- Defining a Process: implement the
Processtrait. Bind a listener, parse requests, honour the shutdown token, lose all memory on reboot. - Writing a Workload: implement the
Workloadtrait. Pick random operations, keep a reference model, assert on every answer, finish in bounded simulated time. - Configuring the SimulationBuilder: wire the pair together. Every public builder method, with a link to the chapter that explains it.
- Running and Observing: run the campaign, read the report, know when it succeeded, and know what stops it.
- Process Groups, Tags and Topology: grow from one role to many. Groups, tags, failure domains, and how a workload finds each of them.
Why This Order
We build bottom-up. The Process comes first because it is the system under test. The Workload comes second because it exercises the Process. The builder ties them together. Running comes last because the report only makes sense once every piece is in place.
Each chapter produces code that the next one uses. After Running and Observing you have a working simulation. The chaos chapters that follow, starting with Chaos in Moonpool, extend it with more fault surfaces.
One rule holds through all of it: the server code is the code you ship. It depends on provider traits, never on tokio directly, so the same KvServer runs under TokioProviders in production and under the simulator’s providers here. Same Code in Production and Simulation explains why this is the foundation everything else rests on.
Defining a Process
- The Process Trait
- The Process Struct
- Implementing run()
- Handling Requests
- Registering with the Builder
- What a Process Knows About Itself
- What the Shutdown Token Gives You
- Key Takeaways
Our key-value server is a Process. It listens for connections, answers get and set requests, and respects the shutdown token. Everything it does goes through providers, never through raw tokio calls.
The Process Trait
The trait has two methods. name() identifies the process type. run() is the main loop, called on every boot.
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Process: Send + Sync + 'static {
fn name(&self) -> &str;
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()>;
}
}
run() returns when the process exits on its own, or never returns because the simulator cancelled it. Both are normal. The trait is Send + Sync + 'static and uses plain #[async_trait], so the process can share state through Arc<RwLock<..>> like any production server.
The Process Struct
A Process is created from a factory on every boot, so the struct starts empty.
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use moonpool_sim::{Process, SimContext, SimulationResult};
struct KvServer;
}
No fields. Each boot gets a fresh KvServer. Everything it accumulates lives until the next crash. A server that must survive reboots writes through ctx.storage(), and Storage Faults shows what that costs.
Implementing run()
The main loop binds a listener, then races accept() against the shutdown token.
#![allow(unused)]
fn main() {
use std::collections::BTreeMap;
use moonpool_sim::{NetworkProvider, TcpListenerTrait};
#[async_trait]
impl Process for KvServer {
fn name(&self) -> &str {
"kv"
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let listener = ctx.network().bind(ctx.my_ip()).await?;
// In memory on purpose: a crash loses it, and the workload's
// reference model must cope with that.
let mut store: BTreeMap<String, Vec<u8>> = BTreeMap::new();
loop {
let accepted = moonpool_sim::select! {
biased;
accepted = listener.accept() => accepted,
() = ctx.shutdown().cancelled() => return Ok(()),
};
if let Ok((stream, _addr)) = accepted {
handle_connection(stream, &mut store).await;
}
}
}
}
}
Binding the listener. ctx.network().bind(ctx.my_ip()) registers this process’s IP for incoming connections. No real port opens. The NetworkProvider and TcpListenerTrait traits must be in scope for bind() and accept().
The store. A BTreeMap on the stack. When the process crashes, the map is gone. The ordered map also keeps iteration deterministic, which HashMap does not.
The select. moonpool_sim::select! is the deterministic twin of tokio::select!. With biased; it polls the branches in order and draws no randomness. Without biased; the branch offset comes from the seed, so the same seed still replays the same choice. tokio::select! is forbidden in simulated code: its branch offset comes from entropy the seed does not control.
The shutdown token. During a graceful reboot the simulator cancels ctx.shutdown() and waits a grace period. The return Ok(()) branch lets the process exit cleanly. During a crash reboot the simulator aborts the task at once, so this branch never runs.
Handling Requests
The connection handler parses a tiny wire protocol: G<key> for get and S<key_len><key><value> for set. A production system keeps its own framing or hands the stream to hyper, as The hyper Stack shows. A raw protocol shows the provider boundary directly.
#![allow(unused)]
fn main() {
use futures::io::{AsyncReadExt, AsyncWriteExt};
use moonpool_sim::network::sim::SimTcpStream;
async fn handle_connection(mut stream: SimTcpStream, store: &mut BTreeMap<String, Vec<u8>>) {
let mut buf = vec![0u8; 4096];
loop {
let n = match stream.read(&mut buf).await {
Ok(0) | Err(_) => return, // closed, or a fault closed it for us
Ok(n) => n,
};
let request = &buf[..n];
let response = match request[0] {
b'G' => {
let key = String::from_utf8_lossy(&request[1..]);
store.get(key.as_ref()).cloned().unwrap_or_default()
}
b'S' => {
let key_len = usize::from(request[1]);
let key = String::from_utf8_lossy(&request[2..2 + key_len]).to_string();
let value = request[2 + key_len..].to_vec();
store.insert(key, value.clone());
value
}
_ => Vec::new(),
};
let _ = stream.write_all(&response).await;
}
}
}
SimTcpStream implements futures::io::AsyncRead and AsyncWrite. The reads and writes are buffer-driven: a read completes on the current poll when bytes are waiting, and otherwise parks until a Delivery event lands them. Under network chaos a read can return an error or zero bytes at any point. The handler treats both as the end of the connection and returns.
Registering with the Builder
.processes() takes a count and a factory. The factory runs on every boot.
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(3, || Box::new(KvServer))
}
This creates three servers at 10.0.1.1, 10.0.1.2 and 10.0.1.3. Each runs independently. Each can be rebooted independently.
A range makes the cluster size part of the seed:
#![allow(unused)]
fn main() {
.processes(3..=7, || Box::new(KvServer))
}
Every seed draws a count between 3 and 7 from the simulation stream. The same seed draws the same count. One .processes() call is one group, named after name(). A system with several roles registers one group per role, and Process Groups, Tags and Topology covers how they share the address plan.
What a Process Knows About Itself
ctx.my_ip() is its address. ctx.client_id() is its index within its group and ctx.client_count() is the group’s size, so three servers number themselves 0, 1 and 2. ctx.topology() answers the rest: its tags, its group, the other process IPs. A process that must crash itself at an exact point of its own protocol calls ctx.crash_self(RebootKind::Crash, None), which Correlated Outages and Self-Crashes describes.
What the Shutdown Token Gives You
Checking ctx.shutdown() is optional. If the process ignores it, a graceful reboot still works: the simulator force-kills the task when the grace period ends. Checking it tests a different code path from a hard crash. The process can:
- Finish in-flight requests.
- Flush write buffers.
- Close connections so peers see EOF instead of a reset.
Attrition: Process Reboots covers the three reboot kinds and the grace period.
Key Takeaways
Every Process follows the same pattern:
- Bind a listener with
ctx.network(). - Race
accept()againstctx.shutdown()withmoonpool_sim::select!. - Use
ctx.time()for every sleep and timeout. - Keep state in memory and expect to lose it, or write it through
ctx.storage(). - Return
Ok(())on shutdown.
The factory produces a blank instance. The simulator manages the lifecycle. Your job is the server logic.
Writing a Workload
- The Workload Trait
- The Workload Struct
- Setup: Finding the Servers
- Run: The Operation Loop
- Why run() Must Finish
- Check: Final Validation
- Events for Invariants
- One Instance or Many
- Patterns That Find Bugs
The workload drives our key-value server and checks that it behaves. It sends requests, keeps a reference model of the expected state, and asserts on every answer while the run is still going.
The Workload Trait
A workload has three lifecycle phases. The runner calls them in order.
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Workload: Send + Sync + 'static {
fn name(&self) -> &str;
async fn setup(&mut self, ctx: &SimContext) -> SimulationResult<()> { Ok(()) }
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()>;
async fn check(&mut self, ctx: &SimContext) -> SimulationResult<()> { Ok(()) }
}
}
- setup runs for every workload before any
run()starts. Bind listeners, find peers, clear state. - run is the main logic. All workloads run concurrently, under chaos when it is enabled.
- check runs after every
run()has returned and the world has settled. AnErrhere fails the seed exactly like a failingrun().
Unlike a Process, a workload survives reboots. The servers die and come back. The test driver keeps running and keeps its model.
The Workload Struct
#![allow(unused)]
fn main() {
use std::collections::BTreeMap;
use std::time::Duration;
use async_trait::async_trait;
use futures::io::{AsyncReadExt, AsyncWriteExt};
use moonpool_sim::{
NetworkProvider, RandomProvider, SimContext, SimulationError, SimulationResult,
TimeProvider, Workload, assert_always, assert_sometimes,
};
struct KvWorkload {
/// Operations per seed. Finite, so the run ends.
num_ops: usize,
/// What the servers should contain, as far as this client knows.
model: BTreeMap<String, Vec<u8>>,
keys: Vec<String>,
}
impl KvWorkload {
fn new(num_ops: usize) -> Self {
Self {
num_ops,
model: BTreeMap::new(),
keys: (0..16).map(|i| format!("k{i}")).collect(),
}
}
}
}
The model is the important field. Every write goes to the server and to the model. Every read is compared against the model.
Setup: Finding the Servers
ctx.topology() knows every participant: which IPs are processes, which groups and tags they carry, which are other workloads.
#![allow(unused)]
fn main() {
#[async_trait]
impl Workload for KvWorkload {
fn name(&self) -> &str {
"kv-client"
}
async fn setup(&mut self, ctx: &SimContext) -> SimulationResult<()> {
if ctx.topology().all_process_ips().is_empty() {
return Err(SimulationError::InvalidState("no kv servers".into()));
}
// A `.workload()` instance is reused across seeds: start each one clean.
self.model.clear();
Ok(())
}
}
Three lookups cover most cases:
#![allow(unused)]
fn main() {
let servers = ctx.topology().all_process_ips(); // every process IP
let one = ctx.peer("kv"); // first process named "kv"
let leaders = ctx.topology().ips_tagged("role", "leader"); // by tag
let acceptors = ctx.topology().ips_in_group("acceptor"); // by group
}
Run: The Operation Loop
The loop picks a server, picks an operation, and checks the answer. Every random choice comes from ctx.random(), so it replays with the seed.
#![allow(unused)]
fn main() {
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let servers = ctx.topology().all_process_ips().to_vec();
for i in 0..self.num_ops {
if ctx.shutdown().is_cancelled() {
break;
}
let server = &servers[ctx.random().random_range(0..servers.len())];
let key = self.keys[ctx.random().random_range(0..self.keys.len())].clone();
match ctx.random().random_range(0..100) {
0..40 => {
let value = format!("v{i}").into_bytes();
match self.send_set(ctx, server, &key, &value).await {
Ok(()) => {
self.model.insert(key, value);
assert_sometimes!(true, "kv: set succeeded");
}
// A fault is an outcome, not a failure. Reaching it is coverage.
Err(_) => assert_sometimes!(true, "kv: set failed under chaos"),
}
}
40..80 => {
if let Ok(value) = self.send_get(ctx, server, &key).await {
let expected = self.model.get(&key).cloned().unwrap_or_default();
assert_always!(
value == expected,
"kv: read matches the model",
{ "key" => key, "server" => server }
);
}
}
_ => {
let _ = ctx.time().sleep(Duration::from_millis(10)).await;
}
}
}
Ok(())
}
}
Two assertion kinds work together here.
assert_always! guards an invariant. A get that disagrees with the model is a definite bug. The optional { key => value } block prints with the seed when it fails, so the report names the key and the server.
assert_sometimes! marks a path that must fire at least once across the campaign. If "kv: set failed under chaos" never fires, the chaos is too weak. If "kv: set succeeded" never fires, the client cannot reach the servers at all.
The request helpers are ordinary provider code. Each one is bounded by a simulated timeout, because a connect to a server mid-reboot can hang until the network heals:
#![allow(unused)]
fn main() {
impl KvWorkload {
async fn send_get(&self, ctx: &SimContext, server: &str, key: &str) -> SimulationResult<Vec<u8>> {
let io = async {
let mut stream = ctx.network().connect(server).await?;
stream.write_all(format!("G{key}").as_bytes()).await?;
let mut buf = vec![0u8; 4096];
let n = stream.read(&mut buf).await?;
Ok::<_, std::io::Error>(buf[..n].to_vec())
};
match ctx.time().timeout(Duration::from_secs(2), io).await {
Ok(result) => Ok(result?),
Err(_) => Err(SimulationError::InvalidState("get timed out".into())),
}
}
}
}
send_set is the same shape with the S framing from Defining a Process.
Why run() Must Finish
A workload returns when it is done. The runner takes the first Ok from any workload as the signal to cancel ctx.shutdown() for every process and every other workload (except under chaos_until_run_ends(), where the runner waits for the last one). A workload that loops forever keeps the seed alive until the stall guards declare a deadlock. Count operations, or sleep for a fixed simulated duration, then return. Running and Observing covers the guards.
A model that only knows what this client wrote is wrong under crashes: a Crash reboot loses the server’s in-memory map, so a later get returns empty where the model expects a value. That is the first bug this workload finds, and it is a real one. The fix belongs in the server, through ctx.storage(), not in the assertion.
Check: Final Validation
check() runs after the world is quiet. No messages in flight, no faults, processes aborted. (With chaos_until_run_ends() it runs on the live processes instead, after the cutoff: the quiet tail where the workload reads the final state back. See Chaos in Moonpool.) It is the place for whole-run properties:
#![allow(unused)]
fn main() {
async fn check(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
assert_always!(
self.model.len() <= self.keys.len(),
"kv: model never holds more keys than the alphabet"
);
Ok(())
}
}
}
check() runs inside the event loop, so network RPCs still work. A workload can also read the captured timeline here through ctx.observability(), which implements TraceQuery.
Events for Invariants
Per-call checks live in the workload. Cross-workload properties live in invariants, which run after every simulation step and read tracing events. The workload emits plain production-style events:
#![allow(unused)]
fn main() {
// After a successful set: the same line an OpenTelemetry subscriber would see.
tracing::info!(key = %key, server = %server, "kv_set_committed");
}
An invariant registered on the builder scans "kv_set_committed" events with a cursor. Events and Invariants covers the capture rule, TraceQuery, and the Invariant trait. For shared values that are not events, ctx.state().publish("key", value) and ctx.state().get::<T>("key") give workloads and fault injectors a typed blackboard.
One Instance or Many
The builder offers three registrations:
.workload(KvWorkload::new(200))keeps one instance across every seed. It getsclient_id0. Clear its state insetup()..workloads(WorkloadCount::Random(1..6), |i| Box::new(KvWorkload::new(200)))builds fresh instances each seed, 1 to 5 of them, each with its ownclient_idandclient_count..workload_factory(|| Box::new(KvWorkload::new(200)))builds one fresh instance each seed.
Exploration and check_determinism() run a seed more than once and require the factory forms. Designing Workloads That Find Bugs explains why one workload with a wide operation alphabet beats several narrow ones.
Patterns That Find Bugs
Reference model. Track expected state. Compare every answer. Any divergence is a bug.
Weighted operation alphabet. Mix writes, reads and pauses, and let swarm testing vary the mix per seed.
Both assertion kinds. assert_always! for what must hold on every call. assert_sometimes! for what the campaign must reach.
Errors are outcomes. A network error under chaos is expected. Log it, count it, move on. The bug is a wrong answer, never an absent one.
Configuring the SimulationBuilder
- The Minimal Builder
- Adding Processes
- Iteration Control
- Invariants
- Chaos
- Every Builder Method
- Putting It All Together
The SimulationBuilder is the glue. It takes your Process, your Workload, your invariants and your chaos configuration, and wires them into a runnable campaign. This chapter walks the calls our key-value simulation needs, then lists every public method with the chapter that explains it.
The Minimal Builder
The smallest simulation has one workload and no processes.
#![allow(unused)]
fn main() {
let report = SimulationBuilder::new()
.workload(KvWorkload::new(100))
.run()?;
}
This places the workload at 10.0.0.1, runs it under the default iteration control, and returns a SimulationReport. run() returns Err(SimulationError::InvalidConfiguration) when the builder cannot run as written: a fault injector or Chaos::Attrition without chaos_duration, or an instance workload under exploration or check_determinism. In that case no seed ran. Failing seeds are never an Err. They are in the report.
Adding Processes
#![allow(unused)]
fn main() {
let report = SimulationBuilder::new()
.processes(3, || Box::new(KvServer))
.workload(KvWorkload::new(100))
.run()?;
}
Three servers at 10.0.1.1 to 10.0.1.3, one client at 10.0.0.1. The workload finds the servers through ctx.topology().all_process_ips(). Call .processes() once per role. Each call is a group on its own 10.0.{g+1}.x range, and Process Groups, Tags and Topology covers groups, .cluster() and .tags().
Iteration Control
One seed is one schedule. Bugs hide in rare interleavings, so a campaign runs many seeds. IterationControl has two modes.
UntilCoverageStable is the default, with plateau_seeds: 10 and max_iterations: 1000. It stops once every observed assert_sometimes! and assert_reachable! has fired and coverage has not grown for plateau_seeds consecutive seeds. Tune it with:
#![allow(unused)]
fn main() {
.until_coverage_stable(10, 5_000) // 10 quiet seeds, 5,000 safety cap
}
FixedCount runs exactly n seeds:
#![allow(unused)]
fn main() {
.set_iterations(100)
}
Generated seeds derive from a wall-clock base seed, so two campaigns explore different seeds. To replay, pin them:
#![allow(unused)]
fn main() {
.set_debug_seeds(vec![42, 7891])
.set_iterations(2)
}
set_debug_seeds supplies the first seeds. set_iterations bounds the count. Without the second call the default control keeps drawing fresh seeds after 42 and 7891. Reproducing with FixedCount is the debugging recipe.
Invariants
Invariants run after every simulation step and read the captured tracing timeline. They catch cross-workload properties while the run is still going.
#![allow(unused)]
fn main() {
use std::cell::Cell;
use moonpool_sim::{Invariant, TraceQuery, assert_always};
struct SingleLeader { cursor: Cell<usize> }
impl Invariant for SingleLeader {
fn name(&self) -> &str { "single_leader" }
fn observe(&self, q: &dyn TraceQuery, _sim_time_ms: u64) {
// A cursor scan: only the events since the last step, never the whole timeline.
for e in q.since("leader_elected", &self.cursor) {
assert_always!(e.u64("term").is_some(), "leader_elected carries a term");
}
}
fn reset(&mut self) { self.cursor.set(0); }
}
// On the builder:
.invariant(SingleLeader { cursor: Cell::new(0) })
.invariant_fn("kv_commits_name_a_key", |q, _t| {
for e in q.snapshot("kv_set_committed") {
assert_always!(e.str("key").is_some(), "kv_set_committed carries a key");
}
})
}
Events and Invariants covers emission, capture and the query API.
Chaos
enable_chaos takes a list of surfaces, each with a ChaosMode. Random samples the full surface every seed. Swarm enables a random subset of its fault families per seed and turns the rest fully off.
#![allow(unused)]
fn main() {
use moonpool_sim::{Attrition, AttritionScope, AttritionVictims, Chaos, ChaosMode};
SimulationBuilder::new()
.processes(3, || Box::new(KvServer))
.workload(KvWorkload::new(200))
.chaos_duration(Duration::from_secs(30))
.enable_chaos([
Chaos::Network(ChaosMode::Swarm),
Chaos::Storage(ChaosMode::Swarm),
Chaos::Attrition {
config: Attrition {
max_dead: 1,
prob_graceful: 0.3,
prob_crash: 0.5,
prob_wipe: 0.2,
recovery_delay_ms: None,
grace_period_ms: None,
scope: AttritionScope::PerProcess,
victims: AttritionVictims::Any,
wipe_budget: None,
},
mode: ChaosMode::Swarm,
},
])
.set_iterations(100)
.run()?;
}
chaos_duration opens the window in which new faults may be injected. When it closes, the runner cancels the injectors and enters recovery mode: every configuration-driven fault family switches off and held partitions heal. Damage already done stays done. The quiet tail between the cutoff and workload completion is where the system under test recovers, and Chaos in Moonpool describes that lifecycle. Each Chaos variant has its own chapter:
| Variant | Covers | Chapter |
|---|---|---|
Chaos::Network(mode) | partitions, clogs, bit flips, random close, latency | Network Faults |
Chaos::Storage(mode) | corruption, misdirection, lost writes, sync lies, stalls | Storage Faults |
Chaos::Attrition { config, mode } | graceful, crash and wipe reboots, one regime per entry | Attrition |
Chaos::Outage { config, mode } | every process of a group loses power at once | Correlated Outages |
Chaos::BuggifyKnobs | push enabled network and storage knobs to extremes | Buggify |
Chaos::Cpu(mode) | every poll costs CPU time on the process’s one core | CPU Time and Gray Failures |
Chaos::GrayFailure { config, mode } | machines that stay alive and slow | CPU Time and Gray Failures |
Every Builder Method
| Method | Purpose | Chapter |
|---|---|---|
new() | Empty builder. Default is the same. | this chapter |
workload(w) | One instance, reused across seeds, client_id 0. | Writing a Workload |
workload_factory(f) | One fresh instance per seed and per explored timeline. | Multiverse Exploration |
workloads(count, f) | WorkloadCount::Fixed(n) or Random(range) fresh instances per seed, indexed. | Writing a Workload |
processes(count, f) | One process group; usize or RangeInclusive<usize> drawn per seed. Panics on a duplicate group name. | Process Groups |
cluster(config, f) | One group laid out over LocalityConfig failure domains; the topology sets the count. | Process Groups |
tags(dims) | Round-robin tag dimensions over the last group. Returns Result. | Process Groups |
link_latency(config) | Distance-dependent latency between .cluster() domains. | Process Groups |
tcp_send_window_bytes(n) | End-to-end byte window per stream direction, every seed. | Network Faults |
accept_backlog_capacity(n) | Maximum unaccepted connections per listener. Must be positive. | Network Faults |
network_fault_mask(mask) | Keep only these network families after per-seed sampling. Draws nothing. | Swarm Testing |
storage_fault_mask(mask) | The storage twin of network_fault_mask. | Swarm Testing |
replicated_storage_faults(config) | Spread storage damage over a group’s failure domains so no record is damaged everywhere. | Aiming Storage Faults |
trace_level(level) | The tracing floor for the subscriber and timeline. INFO by default. | Reproducing |
invariant(i) | Register an Invariant, run after every step. | Events and Invariants |
invariant_fn(name, f) | Closure invariant over &dyn TraceQuery. | Events and Invariants |
metrics_factory(f) | One MetricsSource per node IP per seed, scraped into the report. | Application Metrics |
metric(query) | A MetricQuery evaluated after every successful seed. | Application Metrics |
fault_factory(f) | A fresh custom FaultInjector per timeline. Requires chaos_duration. | Custom Fault Injectors |
chaos_duration(d) | The window in which new faults may be injected. | Chaos in Moonpool |
enable_chaos(surfaces) | Enable Chaos surfaces with a ChaosMode each. | this chapter, Swarm Testing |
swarm_operations() | Per-seed subset of each workload’s operation alphabet via swarm_op_enabled. | Designing Workloads |
set_iterations(n) | IterationControl::FixedCount(n). | this chapter |
until_coverage_stable(plateau, cap) | IterationControl::UntilCoverageStable. The default is (10, 1000). | Running and Observing |
set_debug_seeds(seeds) | Use these seeds first. | Reproducing |
replay_timeline(seed, recipe) | One iteration of seed with an exploration BugRecipe’s RNG breakpoints. | Exemplars and Continuations |
run_time_budget(d) | Virtual-time budget per workload phase. One simulated hour by default. | Running and Observing |
check_determinism() | Run every seed twice and fail it when the draw sequences differ. | Reproducing |
enable_exploration(config) | Frontier exploration from novel assertion outcomes. Needs the exploration feature. | Multiverse Exploration |
run() | Run the campaign. Result<SimulationReport, SimulationError>. | Running and Observing |
Three groups of methods consume no randomness: the masks, the deployment-shape settings (tcp_send_window_bytes, accept_backlog_capacity, link_latency) and trace_level. Adding them to a campaign changes no seed’s schedule. Every Chaos surface, every ranged count and swarm_operations draw from the simulation stream in a fixed order, so adding one shifts every seed. That is intended, and Seed-Driven Reproducibility explains the one-stream rule behind it.
Putting It All Together
#![allow(unused)]
fn main() {
let report = SimulationBuilder::new()
.processes(3, || Box::new(KvServer))
.tags(&[("role", &["primary", "replica"])])?
.workloads(WorkloadCount::Random(1..4), |_| Box::new(KvWorkload::new(200)))
.invariant(SingleLeader { cursor: Cell::new(0) })
.chaos_duration(Duration::from_secs(30))
.enable_chaos([
Chaos::Network(ChaosMode::Swarm),
Chaos::Storage(ChaosMode::Swarm),
Chaos::BuggifyKnobs,
])
.swarm_operations()
.until_coverage_stable(10, 2_000)
.run()?;
}
The builder creates the simulated world, assigns IPs, seeds the stream, runs the orchestration loop, collects assertions and metrics, and produces the report. Running and Observing reads that report.
Running and Observing
- Two Ways to Run
- Reading the Report
- What Success Means
- When Things Fail
- Stop Conditions
- Stall Detection
- Checking Determinism
- Exit Codes
- The Feedback Loop
You have a Process, a Workload and a builder. Now we run the campaign, read the report, and learn what stops a run.
Two Ways to Run
A simulation is ordinary Rust that calls SimulationBuilder::run(). There is no runtime to build: run() drives each seed on a fresh deterministic executor on one OS thread. A bare tokio::spawn inside it panics, because there is no tokio runtime.
As a test. Put the builder in a #[test] and run it with nextest:
nix develop --command cargo nextest run -p moonpool-sim process_groups --nocapture
Fast, coverage-blind, and the way every integration test under crates/moonpool-sim/tests/ runs.
As a binary. The examples in crates/moonpool-sim-examples/src/bin/sim/ are the pattern. xtask builds them with sancov instrumentation and runs them:
cargo xtask sim list # every simulation binary
cargo xtask sim run tonic # binaries whose name matches
cargo xtask sim run-all # all of them
Seeds and iteration counts are builder calls, not CLI flags. A binary’s main is short:
fn main() {
moonpool_sim::init_sim_tracing(tracing::Level::WARN);
let report = SimulationBuilder::new()
.processes(3, || Box::new(KvServer))
.workload(KvWorkload::new(200))
.enable_chaos([Chaos::Network(ChaosMode::Swarm)])
.set_iterations(100)
.run()
.expect("simulation configuration is valid");
report.eprint();
if report.is_failure() {
std::process::exit(1);
}
}
init_sim_tracing installs a stdout tracing_subscriber::fmt at the given level. There is no RUST_LOG plumbing. expect is right here: an Err from run() is a configuration mistake, not a test result. Running Simulation Binaries with xtask covers registering a new binary.
Reading the Report
report.eprint() prints to stderr, coloured when stderr is a terminal and NO_COLOR is unset. format!("{report}") is the same text without colour. A healthy campaign looks like this:
━━━ Simulation Report ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
100 iterations 100 passed 0 failed ✓ 100.0%
Wall Time 12ms avg 1.20s total
Sim Time 45.23s avg
Events 8,432 avg
━━━ Assertions (4) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ always "kv: read matches the model" 12,847 pass 0 fail
✓ always "kv: model never holds more..." 100 pass 0 fail
✓ sometimes "kv: set failed under chaos" 412 / 6,514 (6.3%)
✓ sometimes "kv: set succeeded" 6,102 / 6,514 (93.7%)
The lines that matter:
- ✓ 100.0%: no seed returned an error, panicked or deadlocked.
- 0 fail on an
alwaysrow: no invariant violation. - ✓ on a
sometimesrow: the path fired at least once. A ○ marks one that never did.
Four more sections appear when there is something to show. Violations lists every failed always-assertion. Coverage Gaps lists every sometimes or reachable assertion that never fired. Saturated or Saturation FAILED explains how an UntilCoverageStable run stopped. Seeds lists each seed with its wall time, sim time and event count, failing seeds in red.
What Success Means
A campaign succeeds when two conditions hold at once:
- No always-assertion violated. Every
assert_always!passed on every evaluation, on every seed. - Every sometimes-assertion fired. Every
assert_sometimes!andassert_reachable!was true at least once across the campaign.
Both matter. A run that never violates an invariant but never reaches an error path is not testing enough. A run that reaches every path but tolerates wrong answers is not checking enough.
report.is_failure() covers the first condition and more: it is true when seeds_failing is non-empty, when assertion_violations is non-empty, or when dropped_assertion_allocations is above zero, which means the assertion table overflowed and some evaluations were never counted. Coverage is deliberately not part of it. Check coverage_violations and convergence_timeout yourself when CI should fail on a coverage gap.
When Things Fail
━━━ Simulation Report ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
100 iterations 98 passed 2 failed ✗ 98.0%
Wall Time 12ms avg 1.20s total
Sim Time 45.23s avg
Events 8,432 avg
Faulty seeds: [7891, 42033]
━━━ Assertions (4) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✗ always "kv: read matches the model" 12,800 pass 47 fail
...
━━━ Violations ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✗ assert_always!('kv: read matches the model') failed 47 times out of 12,847
The report says which seeds failed, which assertion broke, and how rare it was. An assertion written with a detail block also printed [ASSERTION FAILED] kv: read matches the model (seed=7891) | key=k3 server=10.0.1.2 on stderr the moment it failed, which is why the workload chapter attached the key and the server. Debugging a Failing Seed takes it from here: pin seed 7891 with set_debug_seeds(vec![7891]) and set_iterations(1), raise the trace level, read backwards from the first violation.
Stop Conditions
How long should a campaign run? Any fixed count is wrong: too small misses bugs, too large burns time. The right answer is to stop on a signal, not a count.
UntilCoverageStable { plateau_seeds, max_iterations } is the default. It stops when every observed sometimes, numeric-sometimes and reachable assertion has fired and coverage has not grown for plateau_seeds consecutive seeds. Under cargo xtask sim run the binary is sancov-instrumented, so the signal is real code coverage, the count of distinct edges exercised. Under cargo nextest run there is no instrumentation and it falls back to assertion coverage, the set of coverage messages that fired. The Saturated section names which signal it used. A campaign with no coverage assertion stops on the plateau alone and logs a warning. max_iterations is a safety cap: hitting it sets report.convergence_timeout = true and prints Saturation FAILED, so CI can fail loudly instead of treating “we ran out of seeds” as success. No fork happens unless enable_exploration is set.
FixedCount(n) runs exactly n seeds. Use it to replay, and when a budget must be predictable.
Stall Detection
A seed must end. Four deterministic guards make sure it does, each a pure function of the simulated schedule, so a replay hits the same guard at the same step.
- No progress. More than three consecutive loop passes with an empty event queue and no workload finished trigger a shutdown request. The same count again after the request declares a deadlock.
- Same-time loop. 100,000 consecutive events at one logical time, such as a re-armed zero-duration sleep, trigger a shutdown request, then a deadlock.
- Run-time budget. Simulated time past
run_time_budget(one simulated hour by default) with workloads still running triggers a shutdown request. Another full budget past that declares a deadlock. This catches a detached task that re-arms a timer every tick while the workload waits on it, which the first guard cannot see. Setup and check phases use the same guard. - Settle timeout. After every workload returns, processes are aborted and the orchestrator drains the queue without yielding. Five simulated minutes of events still pending fails the seed with
SimulationError::SettleTimeout, which surfaces leaked timers and unclosed connections.
A deadlocked seed stops the whole campaign. The report still carries everything earlier seeds gathered, with the deadlocked seed in seeds_failing and convergence_timeout left false.
Two more things end a campaign early. A workload task that panics or is cancelled before it returns its Box<dyn Workload> stops the run, because reusing the survivors would hand one workload another’s identity. An ordinary Err from a workload keeps the instance and fails only that seed. A process that panics fails its seed as well.
Checking Determinism
check_determinism() runs every seed twice and compares a fingerprint after every draw on the simulation stream. The replay is a full run, judged like one. Any uncontrolled difference, a HashMap iterated in random order, a wall-clock read, a static that survives a run, fails the seed with the always-assertion "determinism canary: replay matched the recorded draw sequence" and names the first diverging draw. It doubles the cost and needs factory workloads. Turn it on when a seed stops reproducing, and Reproducing with FixedCount shows how to bisect from there.
Exit Codes
A binary exits 0 on success and 1 on failure. Gate on is_failure():
#![allow(unused)]
fn main() {
if report.is_failure() {
eprintln!(
"ERROR: {} seeds failed {:?}, {} violations, {} dropped allocations",
report.seeds_failing.len(),
report.seeds_failing,
report.assertion_violations.len(),
report.dropped_assertion_allocations
);
std::process::exit(1);
}
}
Gating on seeds_failing alone lets a run with partial assertion accounting pass. The examples’ finish_or_exit_on_failure helper in crates/moonpool-sim-examples/src/support.rs is exactly this.
The Feedback Loop
Write a workload, run it, find a bug, fix the bug, add an assertion so it stays fixed, run again. The assertion table is the scoreboard. Every row ✓ with high counts means the test is thorough and the system is correct. A ○ means a path the chaos is not reaching. A ✗ means a real bug.
Process Groups, Tags and Topology
Our key-value simulation has one kind of server. Real systems have several: a consensus tier and a pool of spares, acceptors and the matchmakers that reconfigure them, a storage tier spread over three datacenters. The builder models all of that with three orthogonal tools. Groups say which role a process plays. Tags label processes inside a group. Failure domains say which processes share fate.
Groups
Every .processes() call registers one group, named after its process type’s name(). Each group has its own factory and its own per-seed count.
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(3..=5, || Box::new(Acceptor)) // name() == "acceptor"
.processes(0..=3, || Box::new(Matchmaker)) // name() == "matchmaker"
}
The counts are drawn from the simulation stream in registration order. Two things follow. A seed replays the same shape. And each role’s size varies independently of the others’, so one campaign covers three acceptors with no matchmaker and five acceptors with three.
Registering the same name twice panics: the name is the group’s identity, so each role needs its own process type or its own name().
IP allocation
Every group owns its own /24. The g-th group registered, counting from zero, lives on 10.0.{g+1}.{1..=N}. Workloads live on 10.0.0.x.
| Entity | Addresses |
|---|---|
| workloads | 10.0.0.1, 10.0.0.2, … |
| first group | 10.0.1.1 .. 10.0.1.N |
| second group | 10.0.2.1 .. 10.0.2.M |
A group’s members are contiguous and recognisable at a glance, and a single-group builder keeps the 10.0.1.x addresses it always had. The plan holds 255 groups of 255 processes. A group that draws zero processes this seed keeps its range: later groups do not shift down.
Asking the topology
ctx.topology() answers group questions from a process or a workload:
| Call | Answer |
|---|---|
groups() | every group name, in registration order, including groups that drew zero this seed |
ips_in_group("acceptor") | the group’s IPs, ascending, the caller included when it is a member |
group_for(ip) | the group an IP belongs to |
my_group() | the caller’s own group, None for a workload |
all_process_ips() | every process IP except the caller’s own |
Every answer is read live: a process a workload spawns mid-run shows up once it boots, and a retired one is gone at once (see Changing the Deployment Mid-Run).
Inside a process, ctx.client_id() and ctx.client_count() are its index within and the size of its own group, now. An acceptor numbers itself 0..n without knowing how many matchmakers this seed drew. The integration test crates/moonpool-sim/tests/process_groups.rs pins all of this:
#![allow(unused)]
fn main() {
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let topo = ctx.topology();
let members = topo.ips_in_group(self.role);
assert_always!(
topo.my_group().as_deref() == Some(self.role),
"process_groups: a process knows its own group"
);
assert_always!(
ctx.client_count() == members.len(),
"process_groups: client_count is the group size"
);
assert_always!(
members.get(ctx.client_id()).is_some_and(|ip| ip == ctx.my_ip()),
"process_groups: client_id indexes the group's members"
);
// ...
}
}
A workload tells “no matchmakers this seed” from “this system has no matchmakers” by checking groups() against ips_in_group("matchmaker").
One fault regime per group
Groups are what role-aware faults aim at. Each Chaos::Attrition entry adds one injector, and AttritionVictims::group(..) scopes its draw and its max_dead budget to one pool:
#![allow(unused)]
fn main() {
.enable_chaos([
Chaos::Attrition { config: regime(1, AttritionVictims::group("acceptor")), mode: ChaosMode::Random },
Chaos::Attrition { config: regime(2, AttritionVictims::group("matchmaker")), mode: ChaosMode::Random },
])
.chaos_duration(Duration::from_secs(10))
}
At most one dead acceptor and at most two dead matchmakers, drawn independently. Chaos::Outage names groups the same way: Outage::groups(["acceptor"]) crashes every acceptor at one instant. ReplicatedFaults::new(DomainLevel::Zone).group("node") spreads storage damage over one group’s domains. A custom injector reads ctx.ips_in_group(..) on its FaultContext. Attrition, Correlated Outages and Aiming Storage Faults cover each.
Tags
Tags label processes inside a group. .tags() attaches dimensions to the last .processes() or .cluster() call. Each dimension is distributed round-robin, independently, and each group’s round-robin starts over.
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(5, || Box::new(ConsensusNode))
.tags(&[
("role", &["leader", "follower"]),
("dc", &["east", "west", "eu"]),
])?
}
Process i gets values[i % values.len()] for each dimension: process 0 is role=leader, dc=east, process 1 is role=follower, dc=west, process 2 is role=leader, dc=eu, and so on. .tags() returns Result<Self, SimulationError> and fails with InvalidState when no group precedes it, so the call takes a ? or an expect.
A process reads its own tags with ctx.topology().my_tags().get("role"). ProcessTags::matches("role", "leader") is the boolean form. The TagRegistry behind ctx.topology() maps every process IP to its tags:
#![allow(unused)]
fn main() {
let leaders = ctx.topology().ips_tagged("role", "leader"); // Vec<String>
let tags = ctx.topology().tags_for("10.0.1.2"); // Option<&ProcessTags>
}
The same registry serves fault injectors: FaultContext::reboot_tagged("role", "leader", kind) reboots every match, and AttritionVictims::tagged("role", "voter") scopes a built-in reboot regime by tag instead of by group. Tags are orthogonal to groups: a group is which factory booted you, a tag is a label the builder handed you. Use a group when the roles are different code, and a tag when one process type behaves differently by configuration.
.cluster(): failure domains
.processes() places processes with no notion of where they run. .cluster() lays a group out across a FoundationDB-style hierarchy of datacenter, zone and machine, so collocated processes share fate.
#![allow(unused)]
fn main() {
use moonpool_sim::LocalityConfig;
SimulationBuilder::new()
// 3 datacenters x 3 zones x 3 machines x 2 processes = 54 processes
.cluster(LocalityConfig::new(3, 3, 3, 2), || Box::new(TopologyProcess))
}
The LocalityConfig is the spawn spec: the product of its four dimensions is the process count. Each dimension takes a fixed count or a range sampled per seed, so LocalityConfig::new(1..=3, 3, 3, 1) varies the datacenter count. Consecutive process indices share a machine, which is what makes a machine-scoped reboot meaningful. A .cluster() call is a group like any other: it gets its own IP range, its own name, and .tags() may still follow it.
Zero-process draws
A dimension may draw zero, as in LocalityConfig::new(0..=2, 1, 1, 1). The group is then empty for that seed: an optional role the deployment runs without. It gets no IPs. ips_in_group(name) is empty while groups() still lists it. Attrition finds no victim in it. A ReplicatedFaults scoped to it draws the Spared pattern. Every dimension is drawn in order even when an earlier one drew zero, so the shape never changes the draw schedule. The same holds for a plain .processes(0..=3, ..) group.
Locality queries
With a cluster the topology answers where things are:
| Call | Answer |
|---|---|
my_locality() | this process’s LocalityInfo (datacenter(), zone(), machine()), None without .cluster() |
locality_for(ip) | another process’s locality |
peers_on_my_machine() | the other processes on the caller’s machine |
ips_in_domain(DomainLevel::Datacenter, "dc1") | every process in a domain |
The topology example in crates/moonpool-sim-examples/src/topology.rs checks these on every boot:
#![allow(unused)]
fn main() {
let collocated = topo.peers_on_my_machine();
assert_always!(
collocated.len() == PROCESSES_PER_MACHINE - 1,
format!("expected {} collocated peer(s), saw {}", PROCESSES_PER_MACHINE - 1, collocated.len())
);
let in_dc = topo.ips_in_domain(DomainLevel::Datacenter, locality.datacenter());
assert_always!(in_dc.len() == PROCESSES_PER_DATACENTER, "every datacenter holds the same count");
}
Domain ids are dc1, dc1-z2, dc1-z2-m3: globally unique, so two datacenters never share a machine id.
What a cluster unlocks
- Correlated reboots.
AttritionScope::PerMachine,PerZoneandPerDatacenterreboot every collocated process together, within themax_deadbudget. UnderChaosMode::Swarmthe regime picks a scope that fits the budget per seed. - Distance-dependent latency.
.link_latency(LinkLatencyConfig::default())keeps a cross-datacenter hop slow on a healthy seed. Pairs without locality, workload clients included, are unaffected. - Replicated fault patterns.
.replicated_storage_faults(ReplicatedFaults::new(DomainLevel::Zone))damages a replicated record in a minority of zones, in a stripe rotation, or one domain at a time, never everywhere.
.processes() and .cluster() mix on one builder, so a consensus tier can be clustered while its clients stay flat.
Choosing Between Them
| Question | Tool |
|---|---|
| Different code, different factory, independent size | a group per .processes() call |
| Same code, a label that changes behaviour | .tags() on the group |
| Which processes die together | .cluster() with a LocalityConfig |
All three are visible to processes, workloads and fault injectors through the same registries, so a workload that targets “a follower in dc2 that shares a machine with a leader” composes ips_tagged, ips_in_domain and peers_on_my_machine without any new API.
Changing the Deployment Mid-Run
The builder draws each group’s boot shape once per seed. Real systems do not keep that shape. An operator racks new machines, decommissions old ones, and starts a new cell, all while clients run. A bug that only shows up while membership changes needs a simulation that changes membership.
Moonpool has no operator actor for this. Every workload gets one handle, ctx.deployment(), and an operator is an ordinary workload that uses it.
The handle
#![allow(unused)]
fn main() {
let deployment = ctx.deployment();
// One new process in "server", booted from the group's factory.
let ip = deployment.spawn("server")?; // "10.0.1.4"
// The same, placed in a failure domain of a .cluster() group.
let ip = deployment.spawn_in("node", DomainSpec::NewZoneIn {
datacenter: "dc1".to_string(),
})?;
// A whole new group on the next /24, ranks 0..3.
let ips = deployment.spawn_group("spare", 3, || Box::new(Spare))?;
// Stop a process for good.
deployment.retire("10.0.1.2")?;
// +1 on every spawn that boots and every retire.
let version = deployment.version();
}
| Call | What happens |
|---|---|
spawn(group) | Reserves the next free host of the group’s /24 now, boots it one tick later from the group’s factory. It takes the group’s next round-robin tags. In a .cluster() group it gets a new machine in the zone of the group’s highest member. |
spawn_in(group, domain) | As spawn, placed by DomainSpec: Machine(id) (co-locate), NewMachineIn { zone }, NewZoneIn { datacenter }, NewDatacenter. New domain ids are never reused. |
spawn_group(name, count, factory) | Declares a new group (the next /24) and spawns count processes from factory. The factory is Send + Sync + 'static. |
retire(ip) | The process leaves every topology query and victim pool at once. One tick later it is killed. It never restarts, and its IP is never reused. Its disk stays readable for a checker (FaultContext::durable_bytes). |
version() | The membership version, also on topology().membership_version() and FaultContext::membership_version(). |
A process gets a handle too, but every call returns an error: only a client changes the deployment. A hand-built SimContext carries a detached handle that refuses as well.
The topology is live
WorkloadTopology and FaultContext read one shared Membership per iteration. They do not hold a copy taken at boot.
ips_in_group,all_process_ips,ips_tagged,ips_in_domain,group_for,tags_forandlocality_forinclude a spawned process once it boots and drop a retired one at once.- A process’s
client_id()andclient_count()are its rank and its group’s size now. A spawned process takes the next rank. A retire moves the ranks above it down. peer_by_nameandpeers()list the entities present at boot only. Find spawned processes by group.
Chaos sees new members
- Attrition draws its victim from the live pool at each decision, and
max_deadcounts against the live pool. A spawned process can be rebooted once it booted, never before. - Outages pick their groups’ live members when they fire.
- Gray failure keeps the windows it drew at the start of the chaos window. Once the deployment changed, a window that starts picks its victim from the live pool (one draw), so a spawned machine can go slow and a retired one never does.
- A fault aimed at a retired IP (
reboot,crash,restart) is dropped.
Determinism
Spawn and retire are simulation events (ProcessSpawn, ProcessRetire), handled by the orchestrator next to the restarts. The IP is reserved at the call. No randomness is drawn. So a seed spawns at the same time with the same IP on every replay, in the explorer’s forks and under check_determinism(). The timeline records process_spawn and process_retire sim faults.
Limits and the report
SimulationBuilder::max_spawns(n) caps the processes spawned per run (default 16). A spawn past the cap returns an error, so a buggy operator cannot fill a /24.
Each iteration’s SimulationMetrics::deployment lists the spawned and retired IPs per group. The report prints a summary line:
Deployment rack_node +140 -93 (spawned, retired)
Example
crates/moonpool-sim-examples/src/operator.rs (cargo xtask sim run operator): an operator workload racks and decommissions nodes of a .cluster() group while a client pings the nodes the topology lists now, under attrition. crates/moonpool-sim/tests/deployment.rs pins each behavior.
Chaos Testing vs Simulation
- Two Schools of Breaking Things
- Chaos Engineering: Learning from Production
- Simulation: Breaking Things Before They Exist
- Simulation Subsumes Chaos
- Complementary, Not Competing
- Moonpool’s Approach
Two Schools of Breaking Things
There are two common ways to test a distributed system under failure. Chaos engineering takes a running system and injects faults into it: kill a VM, cut a network link, fill a disk. Simulation testing builds a fake world and runs the system inside it. The faults come from the world itself. Both break things on purpose. They break things in different ways, and the difference decides how we use each one.
Chaos Engineering: Learning from Production
Chaos engineering, made popular by Netflix’s Chaos Monkey, uses production or a production-like staging environment as the laboratory. You pick an experiment (“what happens if we kill this database node?”). You form a hypothesis (“traffic reroutes within 5 seconds”). You run the experiment against real infrastructure and observe.
This approach has real strengths. It tests the actual system: real configuration, real dependencies, real network stacks. It finds problems no pre-production test can find: a misconfigured load balancer, a stale DNS cache, a gap in monitoring. When chaos engineering finds something, it matters, because it happened in the real world.
But chaos engineering has structural limits:
- Non-deterministic. You cannot reproduce the exact sequence of events that caused a failure. The bug happened once, under conditions you cannot rebuild.
- Reactive. You find problems after they exist in production, not before.
- Slow. Each experiment takes real time on real machines. One scenario takes minutes. A million scenarios is not practical.
- Blast radius. Real users can be affected. An experiment that goes wrong is an outage.
Chaos engineering finds symptoms. The database went down and something bad happened. But why? A race? A missing retry? A lock held too long during recovery? Getting from symptom to cause is forensic work after the fact, often with no way to replay the failure.
Simulation: Breaking Things Before They Exist
Simulation testing takes the opposite path. Instead of injecting faults into a real system, you build a simulated world where faults are a first-class feature. The network drops a connection because the configuration says so. The disk tears a write because the crash model says so. A clock drifts because the simulator makes it drift.
Everything runs in one process on one OS thread, driven by a seeded random number generator. The same seed produces the same execution, every time. A failing test is not a flaky signal. It is a reproducible bug with an exact replay.
The advantages follow directly:
- Deterministic. Every failure replays. A failing seed is a permanent regression test.
- Proactive. You find bugs before the code reaches production, before staging, often before it leaves your machine.
- Fast. No real I/O, no real time. Moonpool simulates hundreds of seconds of cluster time in single-digit real seconds. FoundationDB ran 5 to 10 million simulation runs per night.
- Wide. Different seeds explore different fault combinations. Run enough seeds and you cover failure scenarios no one would write a test for by hand.
Simulation Subsumes Chaos
Here is the key point: simulation subsumes chaos engineering. Everything chaos engineering does, simulation does too, with replay, speed and breadth added on top.
Chaos engineering injects a random partition? Simulation injects a random partition, and you can replay the exact moment with the exact timing. Chaos engineering kills a process? Simulation kills it, restarts it, and verifies the recovery path, in milliseconds of real time.
Will Wilson made this concrete with FoundationDB’s Sinkhole: a rack of real servers wired to programmable power switches, toggled continuously to validate the real system. Sinkhole never found a database bug that simulation had missed. It found bugs in other software and in hardware. The simulation was a stricter, more adversarial environment than reality.
Complementary, Not Competing
This does not make chaos engineering useless. The two are complementary:
Simulation is for development. It runs on every commit, explores millions of fault scenarios, and catches bugs before they ship. It tests the logic of your system against the worst possible world.
Chaos engineering is for production validation. It verifies that your real deployment, with its real configuration and dependencies, behaves as expected. It catches what simulation cannot model: kernel behaviour, cloud provider quirks, misconfigured infrastructure.
Think of simulation as the wind tunnel and chaos engineering as the flight test. You do not skip the wind tunnel because you plan to fly the plane. You do not skip the flight test because the wind tunnel looked good.
Moonpool’s Approach
In moonpool, chaos is a tool inside the simulation, not an alternative to it. Process reboots, correlated outages, network faults, storage faults, slow machines and code-path perturbation are all chaos techniques. They run inside a deterministic simulator, so every fault replays and every failure can be debugged.
Faults enter from two sides. The environment side is the builder: enable_chaos lists the fault surfaces a campaign turns on, and each seed draws its own subset and intensities. The code side is the shipped program: buggify! takes a rare-but-legal path, and hint! names a moment where a fault would be interesting, so the simulator can strike there. Production never sees either. The same binary runs in both worlds, which is the rule Same Code in Production and Simulation explains.
Chaos in Moonpool maps every surface. The chapters after it take one surface each.
Chaos in Moonpool
- Make Rare Bugs Common
- Two Sides of a Fault
- Every Chaos Surface
- ChaosMode: Random or Swarm
- The Chaos Window
- Determinism Is Not Negotiable
Make Rare Bugs Common
Real distributed systems fail in ways that are individually rare and collectively certain. A partition happens once a month. A disk tears a write once a year. A process dies during recovery once in a thousand deployments. Multiply enough small probabilities across enough nodes and enough time, and the question is not if but when.
The principle behind chaos in moonpool is simple: amplify failure probabilities so that rare bugs become common. A monthly partition happens every few seconds in simulation. A yearly torn write happens every hundred writes. A crash at the worst possible moment happens on the seed that chose that moment.
This is not reckless. Because every fault is drawn from the seed, the bug found at high probability is the same bug that would appear at low probability in production, found a thousand times faster, with a replay attached.
Two Sides of a Fault
Faults enter the simulation from two sides, and the split matters.
The environment side is the builder. SimulationBuilder::enable_chaos takes a list of Chaos surfaces. The runner samples each one per seed, runs it during the chaos window, and switches it off when the window closes. The code under test never knows a surface exists.
The code side is the shipped program. buggify! makes a rare-but-legal decision of the code’s own (a tiny batch, a skipped resend). hint! names a moment where an environmental fault would be interesting (a batch is durable but not yet sent), and the simulator decides whether to strike. Both are no-ops in production, so the code that ships is the code that runs under chaos.
Every Chaos Surface
enable_chaos accepts any mix of these variants. Each one is sampled by a ChaosMode where it has one.
| Variant | What it does | Chapter |
|---|---|---|
Chaos::Network(mode) | Clogs, partitions, bit flips, random closes, connect failures, clock drift, buggified delays, slow pairs, black holes, at the TCP connection level. | Network Faults |
Chaos::Storage(mode) | Corruption, EIO, misdirected and phantom writes, sync failures, short transfers, lost directory entries, crash damage, stalls and throttles, disk failure. | Storage Faults, Aiming Storage Faults |
Chaos::Attrition { config, mode } | Reboots processes one at a time (or one failure domain at a time) under a max_dead budget. One injector per entry, so each process group can have its own regime. | Attrition |
Chaos::Outage { config, mode } | A correlated outage: every process of the named groups loses power in the same tick, and each comes back on its own delay. At most once per run. | Correlated Outages |
Chaos::BuggifyKnobs | A modifier. On surfaces already enabled, some seeds push a knob to an extreme (a disk at 100 IOPS, 1% of sends corrupted). Never switches a surface on by itself. | Buggify |
Chaos::Cpu(mode) | The CPU model: every poll of a process task costs CPU time on the process’s one core, so a busy machine answers late. | CPU Time and Gray Failures |
Chaos::GrayFailure { config, mode } | One or more machines stay alive and slow for a long window: CPU, disk or network multiplied by a factor. At most max_slow at once. | CPU Time and Gray Failures |
Two surfaces are not in the list because they are not sampled per seed. Custom fault injectors are registered with fault_factory and script whatever they want through FaultContext, see Custom Fault Injectors. Replicated fault patterns are a shaping of Chaos::Storage, registered with replicated_storage_faults, see Aiming Storage Faults.
#![allow(unused)]
fn main() {
use moonpool_sim::{Attrition, AttritionScope, AttritionVictims, Chaos, ChaosMode, SimulationBuilder};
SimulationBuilder::new()
.processes(3, || Box::new(MyProcess))
.workload(MyWorkload)
.enable_chaos([
Chaos::Network(ChaosMode::Swarm),
Chaos::Storage(ChaosMode::Swarm),
Chaos::Attrition {
config: Attrition {
max_dead: 1,
prob_graceful: 0.3,
prob_crash: 0.5,
prob_wipe: 0.2,
recovery_delay_ms: None,
grace_period_ms: None,
scope: AttritionScope::PerProcess,
victims: AttritionVictims::Any,
wipe_budget: None,
},
mode: ChaosMode::Swarm,
},
Chaos::BuggifyKnobs,
])
.chaos_duration(Duration::from_secs(30))
.run()?;
}
A surface absent from the list stays off. Its configuration is the default: low latency, no faults.
ChaosMode: Random or Swarm
Every sampled surface takes one of two modes.
ChaosMode::Random turns on the whole surface and draws its intensities per seed. Every network family is a little bit on, on every seed. For attrition and outages, Random runs the configured regime as written.
ChaosMode::Swarm turns on a random subset of the surface’s fault families per seed and fully disables the rest. One seed is clog-only. Another is partition plus bit flip. Another is the all-off baseline. For attrition, Swarm draws the reboot regime itself, including the never-reboot regime. For outages, Swarm drops the outage on half the seeds.
Swarm is the mode we recommend. Swarm Testing: Masks and Budgets explains why, lists every mask and budget, and shows how one workload under swarm covers more than many workloads without it.
The Chaos Window
Every surface runs inside the window chaos_duration sets. The builder refuses a campaign that registers attrition, an outage, a gray failure or a fault_factory without one: run() returns SimulationError::InvalidConfiguration instead of never running the injector.
When the window closes, the runner ends chaos in one step:
- It cancels
FaultContext::chaos_shutdown, so injector loops wind down. - It calls
SimWorld::enter_recovery_mode, which turns off every configuration-driven network, storage and block-device fault family and heals the partitions it holds. - It calls
moonpool_buggify::buggify_enter_recovery, so disruptive buggify sites and everyhint!fall silent.
The cutoff bounds fault generation, nothing else. Corrupted sectors stay corrupted. Lost writes stay lost. A process already killed restarts on its own schedule. The cluster is not healthy at the cutoff. The environment has stopped making it worse, and recovering is now the system’s job. The quiet tail between the cutoff and workload completion is where a rebooted process rejoins and a cluster reconverges. Give the workload enough simulated time there, then assert on convergence.
Chaos for the Whole Run
A fixed window has a weakness: the workload’s operations and the faults meet only while both happen to run. If the window is short, most operations run in the quiet tail and never meet a fault. chaos_until_run_ends() uses FoundationDB’s test shape instead. The fault surfaces run for the whole run phase, beside the client workload, and the checks run on a quiet, live cluster:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.enable_chaos([Chaos::Network(ChaosMode::Swarm)])
.chaos_duration(Duration::from_secs(300)) // the cap
.chaos_until_run_ends()
}
In this mode:
- Chaos ends when the last workload’s
run()returns, or atchaos_duration, which is now a cap. The run’s length is the workload’s choice: it can draw one per seed. - A
run()that returns early does not shut its siblings down. check()runs on the live processes after the cutoff. It is the quiet tail: there the system recovers, and the workload reads its final state back and judges convergence. Killed processes come back on their usual schedule, and no new fault starts.- Fault injectors keep running until
check()ends, with theirchaos_shutdowncancelled. The processes are aborted only aftercheck(). chaos_durationis also the reference length of a gray failure’s window. Set it near the longest run the workload draws.
moonpool_sim::chaos_ended_at() gives the cutoff time (and in_chaos() whether it passed), so code that times a step from the cutoff can find it.
Determinism Is Not Negotiable
Every chaos decision is a draw on the one simulation stream. The same seed produces the same faults in the same order at the same times. So:
- A failing seed is a permanent, reproducible bug report.
- You debug a failure by replaying it with tracing turned up.
- Fixing a bug and re-running the seed verifies the fix.
- Different seeds explore different fault combinations on their own.
The draw order is a contract. Network and storage profiles are drawn before the world reseeds the stream. The operation-alphabet mask comes next, then attrition regimes in registration order, then outages, then the CPU model and gray failures. Moving one draw shifts every seed, which is why the runner pins the order and why a surface you leave off costs no draw. The Fault Reference lists every family, its default rate and its mask bit.
Buggify: Fault Injection
- The Idea
- Two-Phase Activation
- Four Injection Patterns
- Disruptive Points and the Recovery Tail
- Named Points a Harness Can Force
- Spiking Config Knobs
- Rate Calibration
- Tilting Behaviour in Simulation
- CPU Charges
- Production Safety
The Idea
Most distributed system bugs do not live on the happy path. They hide in error handlers, timeout branches, retry loops and recovery code. Those paths run only when something goes wrong, so they are the least tested code in the system and the most critical when a failure comes.
FoundationDB solved this with BUGGIFY: scatter conditional fault points through the codebase, active only in simulation. Each point perturbs the code in a small way. Return an error instead of success. Add a delay. Shrink a buffer. Run enough seeds and the perturbations push the code through every error path, every retry, every recovery sequence.
Moonpool implements this as the buggify! family of macros in the zero-dependency moonpool-buggify crate. moonpool-sim re-exports them, so moonpool_sim::buggify! and moonpool_buggify::buggify! share one state.
Two-Phase Activation
A naive design fires every point on every call. That is chaos, but not useful chaos. If every operation fails, the system never makes progress, and you never test the interactions between partial failures.
Moonpool uses FoundationDB’s two-phase model:
Phase 1: Activation. The first time a run reaches a buggify location, the location is activated or deactivated by one draw. The decision holds for the whole run. A deactivated location never fires, however often it is reached. The runner activates each location with a fixed probability of 50% (buggify_init(0.5)).
Phase 2: Firing. Each time an active location is reached, it fires with a per-site probability. buggify! fires at 25%. buggify_with_prob! takes its own rate.
#![allow(unused)]
fn main() {
use moonpool_buggify::{buggify, buggify_with_prob};
// Each call site is one location, keyed by file:line.
// First encounter: a 50% activation coin. Then 25% per call while active.
if buggify!() {
return Err(Error::timeout("simulated timeout"));
}
// Fire at 50% instead of the default 25%.
if buggify_with_prob!(0.5) {
buffer_size = 1; // force single-byte reads
}
}
Neither rate is a builder setting. The two phases mean each seed tests a different combination of active points. Seed 42 activates a request timeout and deactivates a storage error. Seed 43 does the reverse. Run enough seeds and you cover the combinations.
Four Injection Patterns
FoundationDB’s codebase uses BUGGIFY in a few recurring shapes. All of them carry over.
Error injection on a success path. After an operation succeeds, sometimes return an error anyway:
#![allow(unused)]
fn main() {
let result = connection.send(message).await;
if result.is_ok() && buggify!() {
// Force the caller through its error handling.
return Err(Error::io("buggified send failure"));
}
}
Artificial delay. Widen a race window:
#![allow(unused)]
fn main() {
if buggify!() {
time.sleep(Duration::from_millis(100)).await?;
}
}
Parameter randomization. buggify_pick! and buggify_range! draw a value at an active point, with no rand dependency. buggify_pick!(prob, n) returns Some(i) with i in 0..n. buggify_range!(prob, lo..hi) returns Some(v) in the range. Both return None when the point does not fire, and always outside a simulation:
#![allow(unused)]
fn main() {
use moonpool_buggify::buggify_range;
// Most runs use the configured batch size. Some runs use a tiny one.
let batch_size = buggify_range!(0.25, 1..3).unwrap_or(DEFAULT_BATCH_SIZE);
}
Alternative code path. Take a rare path more often:
#![allow(unused)]
fn main() {
let should_compact = needs_compaction() || buggify_with_prob!(0.1);
}
Each of these is a decision of the code. The code needs nothing from the environment to take the path. A crash, a failed sync or a cut link is a different thing. The code cannot decide those, so it names the moment with hint! and the simulator decides. hint!: Naming Fault Sites draws that line.
Disruptive Points and the Recovery Tail
A point that makes an operation fail (a cut session, a refused request) is different from a point that only takes a rare path. If it keeps firing after the chaos window closes, the liveness checks a workload makes in its quiet tail fail on injection that should have stopped. Mark those points with buggify_fault_with_prob!:
#![allow(unused)]
fn main() {
use moonpool_buggify::buggify_fault_with_prob;
if buggify_fault_with_prob!(0.02) {
return Err(Error::overloaded());
}
}
During the chaos window it behaves like buggify_with_prob!. When the runner closes the window, it calls moonpool_buggify::buggify_enter_recovery, and from then until the next run every fault point evaluates to false without a draw. Ordinary buggify! and buggify_with_prob! points keep firing through the tail: a rare-but-harmless path is still worth taking while the system recovers. FoundationDB draws the same line by gating its disruptive points on speedUpSimulation.
Named Points a Harness Can Force
Some decisions are made once per seed: hold one journal for the whole window, withhold a whole class of requests. The plain macros key a location by its file:line, and each activation is an independent coin. A harness that builds a scenario from several such ingredients waits for the product of their coins. buggify_named! keys the location by a stable label instead, so the harness decides it together with the other ingredients:
#![allow(unused)]
fn main() {
use moonpool_buggify::buggify_named;
// Shipped code. With probability 1.0 the location's activation is the
// per-seed decision. Disruptive, so silent in the recovery tail.
if buggify_named!("withhold gc requests", 1.0) {
return;
}
// Harness, once it drew the scenario for this seed.
moonpool_sim::set_activation("withhold gc requests", scenario);
}
set_activation(label, active) replaces the location’s own coin. It draws nothing. Call it before the location’s first encounter, or later to override. buggify_init clears it with every other decision at the start of a run. A point the harness never sets keeps its own coin. Every label must be unique: two points with one label share one activation. Swarm Testing shows how a harness uses this as one more swarm axis.
Spiking Config Knobs
The patterns above scatter buggify! through your own code. The simulation has knobs of its own: network latencies, disk IOPS, fault probabilities. FoundationDB randomizes them the same way, with one line at config construction:
if (randomize && BUGGIFY) KNOB = deterministicRandom()->random(lo, hi);
Moonpool has buggify_knob!(default, lo..hi). It returns default on most seeds. When its call site activates and fires it returns a value drawn inside the range:
#![allow(unused)]
fn main() {
// Most seeds keep the sampled IOPS. A few push the disk to a crawl.
self.iops = moonpool_sim::buggify_knob!(self.iops, 100..5_000);
}
You opt in through the same enable_chaos list as everything else:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.enable_chaos([
Chaos::Storage(ChaosMode::Swarm),
Chaos::BuggifyKnobs,
])
.workload(MyWorkload)
.run()?;
}
Chaos::BuggifyKnobs is a modifier, not a surface. It perturbs knobs only on surfaces you already enabled, so it never switches on a family you left off. On the storage surface the spiked knobs are IOPS, bandwidth, the sync-failure rate, stall and throttle rates and durations, and the disk-failure rate. On the network surface they are the clog, partition, random-close and black-hole rates, and the bit-flip rate, which a seed can push from a sampled 0.001% to 0.02% of sends to around 1%, enough for an integrity check above the transport to meet corruption within a bounded run. Bit flips are spiked only on seeds that enabled them.
This is a different coverage axis from swarm testing. Swarm decides which fault families are on. Knobs decide how hard an enabled knob gets pushed. A seed can run storage-only with a disk throttled to 100 IOPS instead of 25,000. The two compose. buggify_knob! lives in moonpool-sim, not in moonpool-buggify: it is simulation configuration, not application fault injection.
Rate Calibration
Not every point should fire at one rate. FoundationDB uses three tiers:
| Tier | Rate | Use |
|---|---|---|
| High | 5-10% | Common edge cases: buffer boundaries, retry paths |
| Medium | 1% | Standard scenarios: timeouts, connection resets |
| Low | 0.1-0.01% | Rare critical failures: corruption, crash during sync |
The default 25% works for most points. Avoid buggify_with_prob!(0.9) on a fault path: a system that fails 90% of attempts never reaches the interactions between partial success and partial failure.
Tilting Behaviour in Simulation
moonpool_buggify::is_simulated() reports whether this thread runs a simulation (buggify is enabled and a random source is installed). It is FoundationDB’s g_network->isSimulated(). Use it to tilt a rate, a cadence or an extra check in simulation. Never let it change an outcome a caller can observe.
CPU Charges
A simulated poll costs a small base cost when the CPU model is on. The base cost does not know that one poll decoded a large batch and another bumped a counter. cpu_charge lets the code say so:
#![allow(unused)]
fn main() {
use std::time::Duration;
fn fold_batch(records: &[Vec<u8>]) {
// ... the real work ...
// A profile says one record costs about 200 ns to fold.
moonpool_buggify::cpu_charge(Duration::from_nanos(200) * records.len() as u32);
}
}
Inside a simulation with the CPU model on, the process’s clock moves forward by the cost times its slowness. What the process does next (a send, a write, a timer) happens after the work. Everywhere else the call costs one thread-local read. Put a charge only on a hot path a profile shows: an encode, a checksum, a fold.
Production Safety
Buggify is gated on simulation state. Outside a run, every macro evaluates to false. The cost is a thread-local read. You leave the call sites in production code, as FoundationDB leaves BUGGIFY in fdbserver.
[dependencies]
moonpool-buggify = "0.8"
The crate is pure std with zero dependencies, so sans-I/O and production crates depend on it directly, and it compiles for wasm32-unknown-unknown. It owns the disabled-by-default state, the macros, hint! and cpu_charge. When a run starts, moonpool-sim installs its seeded random source with set_random_source, calls buggify_init, and installs the hint and CPU sinks. When the run ends, buggify_reset makes everything inert again.
Two rules keep replay exact. Never mix non-deterministic entropy into a buggified branch: rand::random() breaks the seed. And do not use buggify for business logic. A branch that changes application semantics belongs in the application, not behind a fault coin.
hint!: Naming Fault Sites
- The Code Names a Moment, the Simulator Decides
- A No-Op in Production
- What a Strike Looks Like
- Three Vetoes and a Fourth
- Where to Put a Hint
- A Worked Example: moonpool-journal
- What a Hint Is Not
The Code Names a Moment, the Simulator Decides
Attrition reboots a process wherever a task happens to yield. Volume alone rarely lands a reboot on the one await that matters: the batch is synced, the acknowledgements are not sent yet. That edge is one await wide. The code knows where it is. The simulator does not.
FoundationDB’s server code solves this with if (buggify()) throw please_reboot(), and its simulated worker turns the throw into a reboot. The site is precise, and the site chooses the fault. Moonpool keeps the precision and moves the choice: the code names a moment. It never names a fault.
#![allow(unused)]
fn main() {
use moonpool_buggify::hint;
storage.sync().await?;
hint!("batch durable, not sent").await;
send(messages).await;
}
The label is a human string and a coverage bucket. It carries no semantics the simulator acts on. The simulator strikes the calling process if the seed’s chaos allows it. Today every strike is a reboot, the one fault that tests a storage moment and a network moment alike: it loses the unsynced bytes and the unsent messages at once.
A No-Op in Production
hint! expands to a disruptive buggify site (buggify_fault_internal) with a label. Outside a simulation, buggify is disabled, so the site draws nothing, asks no one, and the returned future is ready at once. The cost is one thread-local read. moonpool-buggify has zero dependencies, so the same code ships to production and to wasm32. This is the rule from Same Code in Production and Simulation: no SimMachine, no test-only hook, one binary.
What a Strike Looks Like
hint! returns a Hinted future. The decision is taken when hint! is called, not when the future is polled. Hinted::strike() reports it as a Strike:
Strike | Meaning | The future |
|---|---|---|
None | No fault fits, the seed’s chaos forbids one, or the caller is no process. | Ready at once. |
Struck | A fault the caller survives. Reserved for storage and network strikes. | Ready at once. |
Killed | The calling process is killed. | Pending forever. |
On Killed the kill is scheduled one scheduler tick away through the same ProcessForceKill event a fault injector uses. The process’s tasks abort, its connections reset, and every write not yet synced resolves by the disk’s crash physics. Nothing after the .await runs. That is why the macro is #[must_use]: a hint you do not await lets the code after the moment run on a process that is already dead.
The simulator finds the process through the task being polled: every task carries the process that spawned it, across spawn_task. A hint from a workload, an injector or the driver has no owner, so the sink answers None and the hint resolves.
Three Vetoes and a Fourth
Three vetoes stand between a hint and a reboot. None of them commands.
1. The site. A hint is a buggify location. It is activated once per run at the runner’s 50%, and it fires per call at a low rate: hint::POINT_PROB (5%) for hint!("label"), or a literal at the site for hint!("label", 0.2). A site at rate 1.0 is a per-seed scenario: activation is the draw. Once the chaos window closes the site is silent, like every buggify_fault_with_prob! point, and the runner removes the sink. The rate is a literal next to the moment it describes, never a knob.
2. The seed’s chaos. The HintSink hands the decision to the first Chaos::Attrition regime whose victims filter admits the process. No regime covers the process? No strike. The regime’s reboot-kind weights pick graceful, crash or crash-and-wipe. Its recovery delay applies. A seed whose swarm drew the never-reboot regime (max_dead = 0) never reboots on a hint either. Register attrition with ChaosMode::Swarm and the hints follow the same per-seed subset as the timed reboots.
3. The budget. The regime’s max_dead counts dead processes among its victim pool. A hint that would exceed it is refused, exactly as a timed reboot would be. The hinting process alone is rebooted, whatever the regime’s failure scope.
4. The harness’s budget. Some budgets the simulator cannot see. A replicated store can lose copies at only so many replicas before a crash stops being survivable, and only the harness keeps that count. It publishes a HintVeto in the run’s state. The sink asks it last, once a reboot fits the regime, so a true answer is a reboot and the harness can count it as spent:
#![allow(unused)]
fn main() {
use moonpool_sim::HintVeto;
// From a process at boot, or from a fault injector at the window's start.
let ledger = ledger.clone();
HintVeto::new(move |ip, _label| ledger.permit_cut(ip)).publish(ctx.state());
}
The veto runs inside the hint’s call, so it must not draw randomness. HINT_VETO_KEY is the state key, and a later publish replaces an earlier veto.
The same veto guards the regime’s own timed reboots of one process. A timed reboot lands at any instant, inside a commit too, so a budget that only hints asked would leak. Before each one, a per-process regime asks the veto with the label TIMED_REBOOT_LABEL. A false answer skips that round. A per-domain regime’s group reboot does not ask it.
The chain costs nothing until a site fires. Every draw (activation, firing, kind, delay) goes through the one simulation stream from inside the call, so a hinted reboot replays exactly. crates/moonpool-sim/tests/hint.rs runs the chain under check_determinism and shows a refused veto leaving the process alive.
Where to Put a Hint
A hint is worth placing where an environmental fault changes what the next boot finds. Two shapes cover most of them:
- After a sync, before a send. The data is durable, and no peer knows. A reboot here leaves the peers unaware of what this process holds.
- After a write, before its sync. The data is staged, not durable. A reboot here tears the write, and recovery must cope with a partial record.
Other good moments: a promise committed before its acknowledgement leaves, a prepare sent before its reply is handled, a metainfo update staged behind a synced batch. A bad moment is a hot path with no state at stake: the site fires often and finds nothing.
A hint is not for a decision of the code. Skipping a resend, shrinking a batch, taking a rare branch: those need no environment, so they are plain buggify! sites. Nothing is both.
A Worked Example: moonpool-journal
moonpool-journal ships three hints inside its batched commit protocol (crates/moonpool-journal/src/journal.rs). The journal depends on moonpool-core and moonpool-buggify only, so every user of the journal gets these power cuts with no code of its own.
#![allow(unused)]
fn main() {
segment.write(batch.entry_at, &batch.entries).await?;
// A power loss here tears entries no record points at yet.
moonpool_buggify::hint!("journal commit: entries written, not synced", COMMIT_HINT_PROB).await;
segment.write(batch.persist_at, &batch.records).await?;
// A power loss here may tear the records, rebuild them from their
// entries, or leave the last batch ambiguous.
moonpool_buggify::hint!("journal commit: records written, not synced", COMMIT_HINT_PROB).await;
segment.sync().await?;
if let Some(meta) = meta {
// The batch is durable and the metainfo still names the old one.
moonpool_buggify::hint!("journal commit: batch synced, metainfo stale", COMMIT_HINT_PROB).await;
meta_file.update(meta).await?;
}
}
COMMIT_HINT_PROB is 0.25, the buggify default, not the 5% of a hot-path point: a commit is short, and a cut inside it is the case its recovery exists for. Each of the three moments maps to a recovery path the journal must take: a torn record, a record rebuilt from its entry, an ambiguous last batch. The ordered protocol writes each log with one durable write (write_at_durable), so its two write-then-sync windows sit inside one call each. The simulated storage names them itself, with hint!("storage: durable write landed, not synced", 0.25) between the write and the sync it models. A harness that counts the replicas it can afford to lose publishes a HintVeto, and the journal never knows. A Crash-Aware Journal follows those recoveries.
What a Hint Is Not
A hint cannot aim at another process. The fleet killer, the rack outage and the scripted crash stay on the harness side, as outages and custom injectors. A process that must crash at an exact point of its own protocol, with no chaos regime in the way, uses SimContext::crash_self instead. That is a command, not a hint, and it is covered with the outages.
The design note behind this API weighed a named fault (“reboot me”), a weakness taxonomy and a resource-scoped statement, and rejected all three: each tells the simulator something it already owns, or lets production code choose a fault. A labelled moment is the smallest thing the code must say.
Swarm Testing: Masks and Budgets
- Passive Suppression
- ChaosMode::Swarm, Surface by Surface
- Caller Masks
- Budgets
- Swarm the Operation Alphabet
- Named Sites as a Swarm Axis
- One Workload, Many Behaviours
Passive Suppression
ChaosMode::Random sets every fault family of a surface to a random non-zero rate. Clogging is a little on, partitions are a little on, bit flips are a little on, all at once, on every seed. That sounds thorough. It is the opposite.
The problem is called passive suppression, and it comes from “Swarm Testing” by Groce, Zhang, Eide, Chen and Regehr (ISSTA 2012). When every feature is always slightly active, the features crowd each other out. To find a clogging bug you often need clogging cranked hard, for a sustained stretch, with nothing else interfering. If partitions keep tearing down the connection you were trying to clog, you never drive clogging deep enough. The all-on configuration explores a narrow band in the middle of the space and never visits the extremes where bugs live. In the paper, swarm testing found 42% more distinct compiler crashes than a hand-tuned default configuration.
The fix is counterintuitive. For each run, turn off a random subset of features entirely. One seed is clog-only. Another is partition plus bit flip. Another is the all-off baseline. Across many seeds you reach the single-family extremes the all-on config never reaches. Will Wilson’s Antithesis talks made the same point for distributed systems.
ChaosMode::Swarm, Surface by Surface
ChaosMode::Swarm applies the paper per surface. Each surface starts from its Random profile, then draws one coin per family and switches the losers fully off. The all-off subset is allowed on purpose: a no-fault run is a valid configuration, and it is where slow leaks show.
| Surface | What Swarm draws per seed |
|---|---|
Chaos::Network(Swarm) | One coin each for clog, partition, bit flip, random close, connect failure, clock drift, buggified delay, pair latency and black hole. |
Chaos::Storage(Swarm) | One coin each for read and write corruption, crash damage, misdirected reads and writes, phantom writes, sync failure, disk stall, disk throttle, disk failure, short transfer, directory-entry loss, read EIO and write EIO. |
Chaos::Attrition { mode: Swarm, .. } | Six draws: max_dead to 0 on half the seeds (the never-reboot regime), one mask per reboot kind, a recovery-delay range scaled to 50% to 200% of the configured one, and with .cluster() a failure scope whose domains fit max_dead. All three kinds masked off is the always-crash regime. |
Chaos::Outage { mode: Swarm, .. } | Two draws: no outage on half the seeds, and no straggler on half the rest. |
Chaos::Cpu(Swarm) | The CPU model on for half the seeds, with per-seed costs. |
Chaos::GrayFailure { mode: Swarm, .. } | Each kind (CPU, disk, network) masked per seed. |
Durations, cooldowns and strategies stay as sampled. They are inert once their family is off.
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.enable_chaos([
Chaos::Network(ChaosMode::Swarm),
Chaos::Storage(ChaosMode::Swarm),
Chaos::Attrition { config: regime, mode: ChaosMode::Swarm },
])
}
Every coin comes from the one simulation stream, seeded for the iteration. Network draws first, then storage, both before the world reseeds the stream. The operation-alphabet mask comes next, then attrition regimes in registration order, then outages, the CPU model and gray failures. A coin is drawn whether or not the family wins, so the count per surface is fixed. Same seed, same subset, every time: a replay rebuilds the subset before the counted run starts, and a fork-explorer recipe never lands on a configuration draw.
Caller Masks
Swarm picks a subset the seed likes. Sometimes you need a subset you like: the system under test does not handle a family yet, or you are hunting one family and want the rest silent. network_fault_mask and storage_fault_mask retain only the families you name:
#![allow(unused)]
fn main() {
use moonpool_sim::{NetworkFault, NetworkFaultMask, StorageFault, StorageFaultMask};
SimulationBuilder::new()
.enable_chaos([Chaos::Network(ChaosMode::Swarm), Chaos::Storage(ChaosMode::Swarm)])
// Hunting a replication bug: no corrupted frames, no lying syncs.
.network_fault_mask(NetworkFaultMask::all().without(NetworkFault::BitFlip))
.storage_fault_mask(
StorageFaultMask::all()
.without(StorageFault::SyncFailure)
.without(StorageFault::DiskFailure),
)
}
Both masks have the same shape: all(), none(), with(fault), without(fault) and contains(fault). The default is all(). A mask is applied after profile sampling and after Chaos::BuggifyKnobs, right before the world is built, and it consumes no randomness. Adding or removing a mask shifts no draw, so recipes and replays stay valid. A family a mask removes is never chosen, whatever the seed’s coin said.
The NetworkFault families are Clog, Partition, BitFlip, RandomClose, ConnectFailure, ClockDrift, BuggifiedDelay, PairLatency and BlackHole. The StorageFault families are Corruption, Eio, Misdirect, PhantomWrite, SyncFailure, ShortTransfer, DirEntryLoss, CrashDamage, Degradation and DiskFailure. Partial reads and writes are TCP behaviour driven by buggify, not a family, so no mask touches them.
Budgets
A mask says which families may strike. A budget says how much damage may stand at once. Every built-in injector carries one:
| Budget | Where | What it bounds |
|---|---|---|
max_dead | Attrition | Dead processes among the regime’s victim pool. Swarm sets it to 0 on half the seeds. |
max_slow | GrayFailure | Machines slow at the same time. |
probability | Outage | Whether a seed has an outage at all. One outage per run at most. |
tolerance | ReplicatedFaults | Failure domains holding damage to one replicated record at once. |
Budgets keep chaos survivable. A system with replication factor 3 tolerates one failure, so max_dead: 1. A budget the simulator cannot see belongs to the harness: a HintVeto refuses reboot hints and an OutageVeto holds an outage back, both published in the run’s state. hint! and Correlated Outages show each one.
Swarm the Operation Alphabet
The same suppression happens inside a workload. A bounded queue driven by a 50/50 push and pop mix is a random walk that takes tens of millions of steps to fill. pop keeps undoing what push does, and the overflow bug is never reached. The fix is the same: enable a random subset of operations per seed.
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.workload_factory(|| Box::new(QueueWorkload::new()))
.swarm_operations()
}
With .swarm_operations() the runner draws a 256-bit mask right after seeding, one bit per u8 operation id, four draws. moonpool_sim::swarm_op_enabled(op_id) reads one bit, consumes no randomness, and gives the same answer every time. Without the call every operation reports enabled, so a non-swarm run is byte-identical to one with no swarm code.
#![allow(unused)]
fn main() {
// Cache the subset once per run. Fall back to the full alphabet if the
// seed disabled everything, so the workload always has something to do.
let enabled: Vec<u8> = (0..NUM_OPS).filter(|&i| swarm_op_enabled(i)).collect();
let enabled = if enabled.is_empty() { (0..NUM_OPS).collect() } else { enabled };
// One draw per step, folded into the subset. A resample loop would make
// the draw count vary, and the explorer keys replay on that count.
let raw = moonpool_sim::sim_random::<u64>();
let op = enabled[(raw % enabled.len() as u64) as usize];
}
The dungeon example in crates/moonpool-sim-examples/src/dungeon.rs is the reference, and crates/moonpool-sim/tests/swarm_op_alphabet.rs shows an assertion that only fires under the swarm. Designing Workloads That Find Bugs has the full pattern.
Named Sites as a Swarm Axis
Buggify is a swarm too: every buggify! location is on for half the seeds and off for the rest. The coins are independent, which is what you want for scattered points and not what you want for a scenario. A harness that needs “hold the journal and withhold GC requests” on the same seed waits for the product of two coins.
buggify_named! keys a site by a label, and set_activation decides it for the run:
#![allow(unused)]
fn main() {
// Shipped code.
if buggify_named!("hold journal", 1.0) { /* ... */ }
if buggify_named!("withhold gc requests", 1.0) { /* ... */ }
// Harness, at the start of the seed: one scenario draw decides both.
let scenario = ctx.random().random_bool(0.5);
moonpool_sim::set_activation("hold journal", scenario);
moonpool_sim::set_activation("withhold gc requests", scenario);
}
A site at rate 1.0 turns activation into the per-seed decision. A site the harness never sets keeps its own coin. buggify_init clears every decision at the next seed. Buggify has the rules.
One Workload, Many Behaviours
A common reflex is to write one workload per scenario: a partition workload, a crash workload, a slow-disk workload, a no-fault workload. Each one is a file to maintain, a report to read, and a fixed configuration that explores one point in the space.
Swarm turns the reflex around. Write one workload that exercises the whole operation alphabet and checks every invariant. Enable every surface with Swarm. Now each seed is a scenario the harness never had to name: seed 7 is partitions and crash-only attrition with pop disabled, seed 8 is a no-fault baseline with the CPU model on, seed 9 is slow disks and a rack outage. The combinations multiply across seeds, and the ones that find bugs are the ones no one would have written.
The per-seed decisions are visible, so a failing seed explains itself. Each attrition injector carries its filter in its name (attrition[group=acceptor]). Every fault the engine injects lands in the timeline as a sim_fault event with a kind field. The assert_sometimes_each! buckets under attrition_reboot_kind and hint_struck say which regime was live. Pin the seed with set_debug_seeds, and the swarm rebuilds the same subset from the seed before the first event runs.
Separate workloads still earn their place when the invariants differ, not when the faults do. A workload that checks linearizability and a workload that checks a compaction schedule are two workloads. A workload that checks linearizability under partitions and another under crashes is one workload under swarm.
Attrition: Process Reboots
- Why Processes Need to Die
- Three Kinds of Reboot
- The Attrition Configuration
- Using Attrition
- Random or Swarm
- The max_dead Constraint
- Rolling Wipes: the Wipe Budget
- Role-Aware Victims: One Regime per Group
- Failure Domains: Correlated Reboots
Why Processes Need to Die
A distributed system that only works while every node is healthy is a single point of failure with extra network hops. Real clusters lose nodes all the time. A rolling deployment restarts a process. A kernel panic kills one. A power failure wipes its storage. Your system must handle all three while it keeps serving requests.
Attrition is moonpool’s built-in injector for that drizzle of failures. During the chaos window it waits 1 to 5 simulated seconds, picks a live victim, kills it in one of three ways, and restarts it after a recovery delay. Then it waits again. The goal is to prove the system tolerates node loss without anyone writing a test per failure.
Three Kinds of Reboot
RebootKind models three real failures.
Graceful is the controlled shutdown. The process’s ctx.shutdown() token fires. The process gets a grace period to drain buffers, close connections and flush writes. If it has not returned when the period ends, it is force-killed. Send buffers drain during the grace period (FIN semantics). Then a fresh instance boots. This models rolling deployments and well-behaved process managers.
Crash is sudden death. The process task is cancelled at the crash instant, before its connections abort, so nothing the crash disturbs can wake it again. Connections abort with no drain. Peers see connection resets. Unsynced storage resolves by the disk’s crash physics. The process runs no work until its restart. This models kernel panics, OOM kills and hardware faults.
CrashAndWipe is the worst case. Same as Crash, and every file the process owns is deleted. The process restarts as a brand-new node. The wipe is scoped to the process’s IP, so other processes keep their disks. This models a replaced machine or a lost disk.
The Attrition Configuration
#![allow(unused)]
fn main() {
use moonpool_sim::{Attrition, AttritionScope, AttritionVictims};
Attrition {
max_dead: 1,
prob_graceful: 0.3,
prob_crash: 0.5,
prob_wipe: 0.2,
recovery_delay_ms: Some(1000..10000),
grace_period_ms: Some(2000..5000),
scope: AttritionScope::PerProcess,
victims: AttritionVictims::Any,
wipe_budget: None,
}
}
max_dead caps the processes dead at the same time among this injector’s victims. A process counts as dead from the instant its reboot is decided, graceful or crash, until its restart runs. Victims are drawn only from live processes, and a reboot aimed at a dead process is skipped, so one outage never schedules two restarts.
prob_graceful, prob_crash, prob_wipe are weights, not probabilities. They need not sum to 1.0. The injector normalizes them and draws a kind. prob_wipe: 0.0 disables wipes. All three at zero means always crash.
recovery_delay_ms is the range a dead process stays dead, drawn per reboot. None means 1000..10000.
grace_period_ms is the range a graceful shutdown may take, drawn per reboot. None means 2000..5000.
scope is the failure domain a reboot targets. PerProcess kills one process. PerMachine, PerZone and PerDatacenter kill every collocated process together and need a .cluster() topology.
victims is who may die at all. AttritionVictims::Any draws over every server process. AttritionVictims::group("acceptor") and AttritionVictims::tagged("role", "voter") narrow the pool.
wipe_budget bounds the wiped processes of the pool that are not yet healed. None (the default) leaves wipes bounded by max_dead alone. Rolling Wipes explains it.
Using Attrition
#![allow(unused)]
fn main() {
use std::time::Duration;
use moonpool_sim::{Attrition, AttritionScope, AttritionVictims, Chaos, ChaosMode, SimulationBuilder};
SimulationBuilder::new()
.processes(3, || Box::new(MyProcess::new()))
.enable_chaos([Chaos::Attrition {
config: Attrition {
max_dead: 1,
prob_graceful: 0.3,
prob_crash: 0.5,
prob_wipe: 0.2,
recovery_delay_ms: None,
grace_period_ms: None,
scope: AttritionScope::PerProcess,
victims: AttritionVictims::Any,
wipe_budget: None,
},
mode: ChaosMode::Random,
}])
.chaos_duration(Duration::from_secs(60))
.workload(MyWorkload::new())
.run()?;
}
chaos_duration is required. run() returns SimulationError::InvalidConfiguration for a builder that registers attrition without it, instead of dropping the injector unrun. When the window closes, no new reboot is scheduled. A reboot already decided still completes: its restart is a scheduled event, so no victim is stranded down into the recovery tail. The tail is where the rebooted process rejoins and the cluster reconverges, so give the workload enough time there.
Random or Swarm
ChaosMode::Random runs the regime as written on every seed. ChaosMode::Swarm draws the regime per seed, in six draws on the simulation stream:
max_deadbecomes 0 on half the seeds. That is the never-reboot regime, which finds the slow leaks and timer bugs that constant restarting hides.- Each reboot kind is masked off on half the seeds. One kind left is a single-mode regime, such as always-crash or graceful-only.
- The recovery-delay range is scaled to between 50% and 200% of the configured one.
- With
.cluster(), the scope is drawn amongPerProcessand the correlated scopes whose domains all fitmax_dead. A flat.processes()campaign keeps its scope.
The injector still makes its usual draws per reboot for kind, delay and victim. The regime costs a fixed footprint at a fixed position, so it replays with the seed. Regimes are drawn in registration order after the stream is reseeded. Swarm Testing explains why the never-reboot seed is as valuable as the crash-heavy one.
The max_dead Constraint
max_dead is the bridge between chaos and correctness. Without it, attrition could kill every node at once, which is chaos but not useful chaos. Set it to what your system claims to survive. Replication factor 3 tolerates one failure, so max_dead: 1. A system that needs 3 of 5 nodes uses max_dead: 2. Attrition then tests failures the system should survive, not failures that no design survives.
The budget is per victim pool. With AttritionVictims::Any it is the whole cluster. With a group or tag filter it is that pool alone, so “at most one dead acceptor” means exactly that, whatever happens to the matchmakers. A reboot hint from the code (see hint!) spends the same budget: the first regime whose filter admits the hinting process decides, and a full budget refuses the hint.
Rolling Wipes: the Wipe Budget
max_dead counts processes that are dead. A wiped process is dead only during its recovery delay. It then restarts, alive but empty, long before the system has rebuilt what it lost. So two wipes a few seconds apart each pass max_dead: 1, and together they can erase two of three replicas. A Paxos deployment with three matchmakers lost two of them that way, 6 s apart.
A wipe budget makes wipes roll through the pool, one healed process at a time:
#![allow(unused)]
fn main() {
use moonpool_sim::{WipeHeal, WipeLedger};
let regime = Attrition {
max_dead: 1,
victims: AttritionVictims::group("matchmaker"),
..base
}
.wipe_budget(1, WipeHeal::Signal); // at most one unhealed wipe
// In the system under test, once a wiped node has caught up:
WipeLedger::heal(ctx.state(), ctx.my_ip());
}
A wiped process is unhealed from the instant its wipe is decided until it heals. While the pool holds max_unhealed unhealed processes, a drawn CrashAndWipe becomes a plain Crash. A group wipe (a machine, zone or datacenter scope) counts every process of the group. A victim that is itself unhealed does not count twice, since wiping it again loses nothing more. The downgrade applies on every path the regime reboots through: timed reboots, failure-domain reboots, and hints.
Two ways to heal:
WipeHeal::Signal: the harness declares a process healed withWipeLedger::heal(state, ip). It returns whether the process was unhealed, so calling it on every catch-up is safe. Only the system knows when a replaced node has caught up. A process never signaled stays unhealed for the rest of the run, so every later wipe in its pool is a crash.WipeHeal::After(duration): healed that long in simulated time after the wiped process restarts. A signal heals it earlier.
WipeLedger::unhealed(state) lists the processes wiped and not yet signaled healed.
The budget costs no randomness. The kind is drawn as before, then judged against the ledger, so a seed draws the same values with or without a budget. Swarm mode copies wipe_budget through and draws nothing for it. Every wipe enters the ledger, including a scripted FaultContext::reboot(ip, RebootKind::CrashAndWipe) and a process that crashes itself with a wipe. A scripted wipe is never downgraded, but it counts against a budgeted pool it lands in.
Role-Aware Victims: One Regime per Group
A cluster rarely has one kind of node. Once you register process groups, uniform attrition draws its victim over every process. On a seed with three acceptors and five spares, most kills land on the spares, and the consensus tier you meant to stress barely notices. Tell attrition who it may kill:
#![allow(unused)]
fn main() {
Attrition {
max_dead: 1,
victims: AttritionVictims::group("acceptor"), // never a spare
..base
}
}
The filter costs no randomness. The victim is drawn uniformly over the eligible processes, so a filter that excludes nothing leaves every seed’s draw schedule untouched, and a filter that leaves nothing eligible skips the round without a draw. It applies to every scope: a machine-scoped reboot only picks machines that host an eligible process and only kills the eligible processes on it.
Because the budget is per pool, each group can have its own regime. Every Chaos::Attrition entry adds one independent injector:
#![allow(unused)]
fn main() {
.enable_chaos([
Chaos::Attrition {
// Acceptors: at most one down at a time, regime swarmed per seed.
config: Attrition { max_dead: 1, victims: AttritionVictims::group("acceptor"), ..base.clone() },
mode: ChaosMode::Swarm,
},
Chaos::Attrition {
// Matchmakers: hammered independently, up to three down at once.
config: Attrition { max_dead: 3, victims: AttritionVictims::group("matchmaker"), ..base },
mode: ChaosMode::Random,
},
])
}
The two injectors run side by side and never block each other. In the report each carries its filter in its name (attrition[group=acceptor], attrition[tag=role=voter]), so a failing seed says which regime was live.
Failure Domains: Correlated Reboots
max_dead: 1 protects you from two random deaths at once. Real outages are rarely random and rarely one node at a time. A host reboots and every process pinned to it dies together. These are correlated failures, and they break quorum logic because the nodes that die were never independent.
Flat IPs cannot express this, so moonpool uses FoundationDB’s hierarchy: datacenter, zone, machine, process. You describe it with .cluster() and a LocalityConfig, which Process Groups, Tags and Topology covers. With a topology in place, scope earns its keep:
#![allow(unused)]
fn main() {
.enable_chaos([Chaos::Attrition {
config: Attrition {
max_dead: 2, // one machine's worth of processes
scope: AttritionScope::PerMachine,
..base
},
mode: ChaosMode::Random,
}])
}
PerMachine picks a machine and reboots every process on it in the same instant, as a host kernel panic would. PerZone does the same one level up, and PerDatacenter takes a region. max_dead still counts processes, and a group reboot is atomic against it: the injector only kills a domain when the whole group fits the remaining budget. With two processes per machine, max_dead: 2 lets exactly one machine be down and never leaves a machine half-dead. A domain with no live eligible process is never drawn. Without a topology the correlated scopes do nothing.
A machine reboot and a rack power loss look alike but differ in one way: attrition stays under max_dead, so a quorum always survives. A fault that takes a whole group at once, past any budget, is Chaos::Outage, in Correlated Outages and Self-Crashes. A scripted sequence (“crash node X, wait for the workload, bring X back”) is a custom injector, in Custom Fault Injectors.
Correlated Outages and Self-Crashes
- The Whole Rack Goes Dark
- Chaos::Outage
- Why the Fault Is Trustworthy
- OutageLanded: Acting at the Instant No Copy Lives
- OutageVeto: A Budget the Simulator Cannot See
- Self-Crashes: A Process Chooses the Instant
The Whole Rack Goes Dark
Attrition never takes more than max_dead processes at once, and that is the point: it models the steady drizzle of failures a healthy cluster rides through. A rack-wide power loss is a different fault. Every process of a group loses power at the same instant. Every memory is gone. What survives is exactly what the disks hold, and no peer can repair a copy from memory, because no peer is awake. Then the machines come back one by one, each on its own schedule, and one straggler takes much longer than the rest.
FoundationDB’s simulator models this as killDataCenter in sim2.actor.cpp: it kills every machine of a datacenter in one loop and lets each machine’s own reboot delay bring it back. A quorum protocol that never faces it has never had its recovery path tested from cold disks alone.
Chaos::Outage
#![allow(unused)]
fn main() {
use std::time::Duration;
use moonpool_sim::{Chaos, ChaosMode, Outage, SimulationBuilder};
SimulationBuilder::new()
.processes(3, || Box::new(Acceptor::new()))
.processes(2, || Box::new(Matchmaker::new()))
.enable_chaos([Chaos::Outage {
config: Outage {
start: Duration::from_secs(10)..Duration::from_secs(25),
down: Duration::from_secs(1)..Duration::from_secs(5),
straggler: Some(Duration::from_secs(20)..Duration::from_secs(40)),
..Outage::groups(["acceptor"])
},
mode: ChaosMode::Swarm,
}])
.chaos_duration(Duration::from_secs(30))
}
Outage::groups names the process groups that lose power together (their Process::name). It sets probability to 1.0, start to the first ten seconds of the window, down to one to ten seconds, and no straggler. Override the fields you care about.
| Field | Meaning |
|---|---|
groups | The groups whose live processes crash together. Must name registered groups. |
probability | Whether a seed has an outage at all. In 0..=1. |
start | When the outage strikes, measured from the opening of the chaos window. A draw past the window’s end means no outage. |
down | How long each victim stays down, drawn per victim. |
straggler | How long the one straggler stays down. Must start at or after the end of down, so the straggler is always the last one back. |
The builder validates the regime before the first seed. An unregistered group, a probability outside 0..=1, a range that ends before it starts, or a straggler range inside down returns SimulationError::InvalidConfiguration from run(). So does an outage registered without chaos_duration.
At most once per run, with probability, at a time drawn from start, every live process of the named groups crashes in the same tick. Each one restarts after its own draw from down. With a straggler range, one victim, picked at random, is held down for a draw from that range instead. A process already dead at the outage instant is not a victim: it comes back on its own schedule. A .cluster() group whose per-seed count drew zero has no live victim, so the outage lands on no one and the run goes on.
ChaosMode::Random keeps the outage on every seed, still subject to probability. ChaosMode::Swarm drops the outage on half the seeds and the straggler on half the rest, in two draws made whether or not the outage survives.
Why the Fault Is Trustworthy
Two properties make the outage safe to build on.
Every draw comes from the simulation stream. The probability and start are drawn when the window opens. The delays are drawn at the outage instant, in ascending IP order. A seed or an exploration recipe replays the same kill and restart schedule.
Each restart is a scheduled ProcessRestart event, not a step of the injector. A restart lands even after the chaos window closes and the injector is gone, so a victim is never stranded down into the recovery tail. Victims count toward dead_count, so attrition holds off while they are down. Give the workload enough time after the window for the last straggler to come back.
OutageLanded: Acting at the Instant No Copy Lives
The injector tells you when it has landed. Once every victim’s kill has run, it publishes an OutageLanded under OUTAGE_STATE_KEY in the run’s StateHandle:
#![allow(unused)]
fn main() {
use moonpool_sim::{OUTAGE_STATE_KEY, OutageLanded};
if let Some(landed) = ctx.state().get::<OutageLanded>(OUTAGE_STATE_KEY) {
// landed.at: the instant the kills were decided.
// landed.victims: every victim, ascending IP order.
// landed.down: each victim's time down, parallel to victims.
// landed.straggler: the one held longest, if any.
}
}
A custom injector that waits for that key can plan latent disk damage at the one moment no copy lives in memory anywhere. A process can read it after its own reboot to learn it came back from a group-wide cut.
OutageVeto: A Budget the Simulator Cannot See
The harness can hold an outage back. A replicated store with a commit in flight would be left ambiguous by a cut, and only the harness knows a commit is in flight. Publish an OutageVeto under OUTAGE_VETO_KEY:
#![allow(unused)]
fn main() {
use moonpool_sim::OutageVeto;
// From a fault injector at the start of the window.
let ledger = ledger.clone();
OutageVeto::new(move |victims| ledger.safe_to_cut(victims)).publish(ctx.state());
}
At the outage instant, before it draws anything, the injector asks the veto with every victim’s IP in ascending order. A true answer is an outage: every victim crashes at once, so the harness may record it as done. A false answer leaves every victim alive, and the injector asks again VETO_RETRY (1 ms) later, with the victims live at that moment, until the veto permits the outage or the chaos window closes. The veto must not draw randomness, because its answer must not move the draw schedule.
It is the outage’s form of HintVeto: one answer for the whole victim set, so a budget that counts victims together is judged once. crates/moonpool-sim/tests/outage.rs shows a veto that holds the outage until a chosen instant and records every set it was asked about.
Self-Crashes: A Process Chooses the Instant
An outage and attrition are the environment’s decisions. A hint! is a request the simulator may refuse. Sometimes a test needs a crash at an exact point of a process’s own protocol, with no regime in the way: mid-append, between two syncs, right after a lease is written. SimContext::crash_self is that command.
#![allow(unused)]
fn main() {
use std::time::Duration;
use moonpool_sim::RebootKind;
file.write_at(0, &new_sectors).await?;
// The kill lands one tick from now, while the sync is in flight.
ctx.crash_self(RebootKind::Crash, Some(Duration::from_millis(50)))?;
file.sync_all().await?; // never completes
}
The call schedules the same force kill a fault injector’s reboot or crash does, one scheduler tick away. The process’s tasks abort, its connections reset, and its storage goes through crash resolution, so every write not yet synced, including the one whose sync is in flight, resolves by crash physics. RebootKind::CrashAndWipe also wipes the disk. Call it, then keep awaiting the work it interrupts: the kill lands before that work’s next storage completion.
With Some(delay) the process restarts that long after the kill. With None it stays down until a fault injector restarts it. No randomness is drawn, and the process counts as dead until it restarts. Two calls are errors: RebootKind::Graceful (a process shuts down gracefully by returning) and a call from a workload (only a process can crash itself).
Code that holds no SimContext, such as a driver’s hooks generic over Providers, takes a handle at boot:
#![allow(unused)]
fn main() {
// A SelfCrash is Clone + Send + Sync + 'static, so it rides into a hook.
let handle = ctx.self_crash()?;
handle.crash(RebootKind::Crash, Some(Duration::from_millis(10)))?;
}
crates/moonpool-sim/tests/crash_self.rs crashes a process between a write and its sync and checks, on the next boot, that every sector resolved to one of the two values the barrier-bounded crash model allows. Use a self-crash when the test is the point. Use a hint! when the shipped code should name the moment and the seed should decide.
Network Faults
- Connections, Not Packets
- Turning Faults On
- The Nine Families
- Partitions
- Black Holes
- Closing a Connection
- Deployment Shape
- Masks and Recovery
Connections, Not Packets
Moonpool injects network faults at the TCP connection level. FoundationDB made the same choice. A distributed system does not see packets. It sees connections that drop, stall, or report success on one side and failure on the other. Those are the faults that reach application code: reconnection, retries, leader election on disconnect, and reconciliation after a partition.
TigerBeetle simulates packets, which is the right tool for testing a network stack. For an application on top of TCP, connection faults reach the code paths that fail in production.
Turning Faults On
Every network fault lives in NetworkConfiguration, under its chaos field (a ChaosConfiguration). The builder picks a configuration per seed from the Chaos::Network entry:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(3, || Box::new(Node::new()))
.workload(Client)
// Each seed enables a random subset of the nine fault families.
.enable_chaos([Chaos::Network(ChaosMode::Swarm), Chaos::BuggifyKnobs])
.chaos_duration(Duration::from_secs(30))
}
| Entry | Configuration the seed gets |
|---|---|
| none | NetworkConfiguration::default(): random close at 0.001%, clock drift up to 100 ms, buggified sleep delay, probabilistic connect failures. Clogs, partitions, bit flips, black holes and pair latency are off. |
Chaos::Network(ChaosMode::Random) | NetworkConfiguration::random_for_seed(): every family on, with per-seed probabilities, durations and a per-seed PartitionStrategy. |
Chaos::Network(ChaosMode::Swarm) | swarm_for_seed(): the Random profile, then each of the nine families is switched off with 50% probability. The all-off subset is allowed. |
Chaos::BuggifyKnobs composes on top. For a seed where a buggify_knob! site fires, one knob jumps to an extreme: 2 to 5 clogs per second, 0.5 to 2 partitions per second, random closes and black holes at 1% to 10%. A knob only spikes a family the seed already has. It never turns on a family the swarm turned off. See Swarm Testing for why “a little of everything” finds fewer bugs than “a lot of a few”.
NetworkConfiguration::fast_local() is the no-chaos profile for unit tests, with microsecond latencies and ChaosConfiguration::disabled().
The Nine Families
Each family is one NetworkFault variant, one bit of the swarm mask, and a few knobs on ChaosConfiguration.
NetworkFault | Knobs | What it finds |
|---|---|---|
Clog | clog_rate (clogs per simulated second), clog_duration (100 to 300 ms) | Code that assumes a write lands promptly. One IP’s sends, receives or both stall on every connection, then resume in order (FoundationDB’s RandomClogging). |
Partition | partition_rate (partitions per simulated second), partition_duration, partition_strategy | Split brain, quorum loss, failure detectors. See below. |
BitFlip | bit_flip_probability, bit_flip_min_bits/max_bits (1 to 32), bit_flip_cooldown | Missing checksums. The flip count follows a power law. |
RandomClose | random_close_probability, random_close_cooldown, random_close_explicit_ratio (30%) | Reconnection and redelivery. 30% of closes return an error, 70% go silent and end in EOF, FoundationDB’s ratio. |
ConnectFailure | connect_failure_mode (Disabled, AlwaysFail, Probabilistic), connect_failure_probability | Missing connect timeouts. In Probabilistic mode a buggified connect returns ConnectionRefused, and a small share of the others (one in ten) hangs forever. |
ClockDrift | clock_drift_enabled, clock_drift_max (100 ms) | Leases, TTLs, anything that compares clocks across nodes. |
BuggifiedDelay | buggified_delay_enabled, buggified_delay_max, buggified_delay_probability (25%) | Timeouts. TimeProvider::sleep stretches by a power-law delay, only inside the chaos window. |
PairLatency | max_pair_latency (off at ZERO..ZERO) | A stably slow peer. Each ordered IP pair draws one extra latency at first contact and keeps it for the run (FoundationDB’s SimClogging). |
BlackHole | black_hole_probability, black_hole_cooldown | Requests without a deadline. See below. |
Bit flips are off in the default profile, because TCP delivers what was written: only a protocol that checksums its frames can tell a flip from data. FoundationDB flips bits only where FlowTransport verifies a packet checksum. If your protocol has no checksum (HTTP/2, gRPC), mask the family out.
Two shapes are always on and are not families: short writes and short reads. A write delivers a random prefix of up to partial_write_max_bytes (1000), and a read returns a random prefix of what is buffered, up to partial_read_max_bytes, always at least one byte, because a zero-byte read means EOF. Code that calls read once and expects a whole message breaks here first.
A fault coin is only rolled when an operation can make progress. A re-poll of a read with no data, or a write under backpressure, returns Pending and draws nothing, so runtime-specific spurious polls do not shift the seed.
Partitions
A partition cuts one direction between two IPs. Random partitions arrive as a Poisson process in simulated time, at partition_rate per second. The count does not grow with traffic: a busy run and a quiet run see the same number. At most one random partition is in force at a time. The next one is drawn from the moment the current one heals, so every partition is followed by a healed gap (TigerBeetle’s partition stability). Clogs arrive the same way, at clog_rate per second for the whole network, and may overlap as in FoundationDB.
partition_strategy decides who is cut when a partition arrives:
| Strategy | Cut |
|---|---|
Random | Random IP pairs |
UniformSize | A random group of 1 to n-1 nodes from the rest |
IsolateSingle | One node from everyone |
IsolateZone | Every process of one zone from the rest |
IsolateDatacenter | Every process of one datacenter from the rest |
AsymmetricSend | One node’s outgoing traffic only |
AsymmetricRecv | One node’s incoming traffic only |
IsolateZone and IsolateDatacenter read the .cluster() topology. Without one they fall back to Random, so every seed can draw them. The asymmetric pair finds the split brain where a node hears every heartbeat but nobody hears its answers. Each arm records its own fault event (SendPartitionCreated, RecvPartitionCreated) on the timeline.
A partition stalls bytes. It never drops them. A chunk queued or in flight on the cut direction freezes where it is and is re-timed by the length of the cut when the partition heals, with no new random draw. The peer reads the original bytes in order, or sees the connection fail. It never reads a later chunk in place of a missing one, which would corrupt the next frame of h2 on the same connection. A FIN is a chunk like any other, so the peer sees the last bytes, then EOF, never EOF first.
A scripted injector cuts and heals by hand:
#![allow(unused)]
fn main() {
// Inside a FaultInjector: isolate the leader for two seconds.
ctx.partition(&leader, &follower)?;
if ctx.time().sleep(Duration::from_secs(2)).await.is_err() {
return Ok(()); // the run is shutting down
}
ctx.heal_partition(&leader, &follower)?;
}
FaultContext::partition cuts both directions. FaultContext::heal_partition and is_partitioned complete the set. On SimWorld, partition_pair(from, to, duration) adds one directed entry, restore_partition(from, to) removes both, and partition_send_from(ip, duration) / partition_recv_to(ip, duration) are the one-way cuts.
Black Holes
A silent close ends in EOF. A black hole never ends. When the coin fires, one direction (this side’s sends, the peer’s, or both) starts delivering nothing. Bytes are accepted into the send window and vanish. The peer’s reads stay Pending with no data and no EOF. Both ends look alive. Only the caller’s own deadline can notice, which is the point: a request without a timeout hangs here forever.
#![allow(unused)]
fn main() {
let mut config = NetworkConfiguration::fast_local();
config.chaos.black_hole_probability = 0.0001; // per I/O, like random close
config.chaos.black_hole_cooldown = Duration::from_secs(5);
}
A black hole is permanent for its connection. The only way out is a new connection. A process kill still resets its connections, so a hung peer is never mistaken for a dead one. SimWorld::black_hole_connection(id, hole_send, hole_recv) is the scripted form.
Closing a Connection
Moonpool models three ways a connection ends, and your protocol must survive all three:
- Graceful close: the closer’s FIN goes behind every queued byte. The peer reads everything, then EOF. Dropping a drained stream does this.
- Write shutdown:
AsyncWrite::closesends the FIN, further writes fail withBrokenPipe, and the read half stays open. The reply to an EOF-delimited request still arrives. - Abort: no FIN, no drain. The peer gets a reset on its next read or write. A process crash does this, and so does dropping a stream with unread bytes.
HTTP/1.1 chunked bodies and gRPC trailers both depend on reading after the peer’s shutdown. A simulator with only aborts never finds those bugs.
Deployment Shape
Three builder settings describe the network rather than break it. They apply on every seed and draw no randomness:
| Builder method | Default | Effect |
|---|---|---|
.link_latency(LinkLatencyConfig) | none | Per-distance one-way latency: same_machine 10 to 50 µs, same_zone 100 to 500 µs, same_datacenter 0.5 to 2 ms, cross_datacenter 20 to 80 ms. Each IP pair samples once at first contact. Needs a .cluster() topology. |
.tcp_send_window_bytes(n) | 64 KiB | The end-to-end byte window per direction. Credit returns only when the peer’s application reads. A small window makes a slow reader back its writer up early. |
.accept_backlog_capacity(n) | 128 | Unaccepted connections per listener. A full backlog parks connect() until an accept() returns. |
Flow control is entropy-neutral: a Pending on a full window and the wake after a read draw nothing. Congestion control and retransmission are out of scope. The model is a reliable ordered byte stream with a bounded number of bytes outstanding.
Masks and Recovery
A NetworkFaultMask removes a family after the seed’s profile is sampled. It draws nothing, so the seed’s other values, its RNG call count and any exploration recipe stay the same:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.enable_chaos([Chaos::Network(ChaosMode::Swarm)])
// gRPC over h2 has no frame checksum: a flipped bit is just data.
.network_fault_mask(NetworkFaultMask::all().without(NetworkFault::BitFlip))
}
A mask only removes. NetworkFaultMask::all() is the default and changes nothing.
At the chaos_duration cutoff the runner calls SimWorld::enter_recovery_mode(). It zeroes every fault probability, heals every partition and makes every machine healthy. It does not repair damage: flipped bytes stay flipped, a closed connection stays closed, a black hole stays open, and a pair latency already sampled stays slow. The quiet tail is where the reconnect has to happen. The Configuration Reference lists the full boundary contract, and the Fault Reference lists every knob.
One FoundationDB result is worth keeping in mind when you write a custom injector: restoring connections in reverse order of disconnection finds more bugs than restoring them in order. Will Wilson calls it swizzling, and nobody reasoned their way to it. The simulator tried both and the data decided.
Storage Faults
- Disks Lie
- The Storage Layering Rules
- Turning Faults On
- Timing, Episodes and Dead Disks
- The Barrier-Bounded Crash Model
- Directory Entries Crash Too
- Failed Writeback (fsyncgate)
- The Lost-Synced-Write Oracle
- Per-Process Disks
Disks Lie
write() returns success and the bytes never reach the platter. fsync() completes and the firmware never flushed its cache. A firmware bug sends a write to the wrong sector. TigerBeetle’s documentation lists these with real incidents behind them, including studies where 8.5% of SATA drives developed silent corruption. Moonpool’s storage faults follow TigerBeetle’s taxonomy and FoundationDB’s crash model, so your integrity code meets these failures in simulation, deterministically.
The Storage Layering Rules
Before the faults, the API they land on. Moonpool has one provider, one file abstraction, one simulated file image. Every API (stream, positioned, block) reads and writes the same bytes.
| Layer | Owns | Methods |
|---|---|---|
StorageProvider | The filesystem namespace | open, exists, delete, rename, create_dir_all, list_dir, sync_dir |
StorageFile | Already-open bytes | AsyncRead/AsyncWrite/AsyncSeek, read_at, write_at, write_at_durable and their zero-copy *_aligned forms, sync_all, sync_data, size, set_len, allocate, constraints, is_direct_io |
BlockFile<F> | Block arithmetic over one open file | read_blocks, write_blocks, write_blocks_durable and their *_aligned forms, sync, sync_data, grow_to_blocks, size_in_blocks, buffer |
Database needs are options on opening an ordinary file, never a second provider stack. There is no BlockProvider, no DirectIoProvider and no region-addressed device:
#![allow(unused)]
fn main() {
// A pager opens an existing file and demands uncached I/O.
let pages = provider
.open("db/pages", OpenOptions::read_write().direct_io(DirectIo::Required))
.await?;
let blocks = BlockFile::new(pages, 4096)?;
let mut page = blocks.buffer(1)?; // aligned for this file
blocks.read_blocks(7, page.as_mut_slice()).await?;
}
DirectIo has three policies. Disabled is buffered I/O. Optional asks for uncached I/O and accepts a fallback, and file.is_direct_io() tells you which one you got. Required fails the open instead of downgrading, and it opens an existing file only. Creating a file and publishing its name is a namespace protocol that belongs to the format, so a journal bootstraps its own file: create it with an ordinary open, sync_all it, sync_dir its parent, then reopen it with Required.
Keep four distinctions, because code hides behind their opposites:
- Direct I/O is not durability. An uncached write is visible, not durable, until
sync_allorsync_datacompletes, or unless it was awrite_at_durable. A durable write covers its own range only, likeO_DSYNC: it does not make an earlier unsynced write elsewhere in the file durable. - The caller’s block size is not the file’s I/O alignment, and neither is a crash-atomicity unit.
file.constraints()reports offset, length and memory alignment separately. A two-block write tears across a crash like any other. - Growing a file is not preallocation.
- File durability is not directory-entry durability.
sync_allmakes the bytes and the length survive. OnlyStorageProvider::sync_dir(parent)makes the name survive.
A file with alignment constraints refuses the stream API with ErrorKind::Unsupported, on both backends, because a shared cursor cannot stay aligned. read_at and write_at are what such a file offers. Both return the count moved, which may be short. BlockFile exists to loop over short transfers so a pager does not.
Zero copy: owned buffers
Every positioned transfer has a borrowed form (&[u8], &mut [u8]) and an owned form over an AlignedBuf (read_at_aligned, write_at_aligned, write_at_durable_aligned; on BlockFile, read_blocks_aligned, write_blocks_aligned, write_blocks_durable_aligned). They are one transfer with one contract. The owned form moves the buffer in and hands it back with the count, so the caller reuses it; an error drops it.
The difference is a copy. The Tokio backend runs positioned I/O on the blocking pool, and a borrowed slice cannot cross into a 'static task: the borrowed form copies the bytes into an aligned bounce buffer before a write and out of it after a read. The owned form moves the caller’s buffer into the task instead, so the kernel transfers straight from and into it. A storage engine that encodes its pages or log blocks directly into an AlignedBuf never copies them on the way, which is what moonpool-journal does.
#![allow(unused)]
fn main() {
let mut page = blocks.buffer(1)?; // aligned for this file
encode_page(page.as_mut_slice()); // built in place
let page = blocks.write_blocks_durable_aligned(7, page).await?; // no copy
}
BlockFile’s owned methods are forgiving where the borrowed ones refuse: a buffer that misses the file’s memory alignment is copied once into one that meets it, and that one comes back. Only the first request moves the buffer; the rare short transfer is resumed through the borrowed form. AlignedBuf::truncate shortens a buffer in place (the last, partial chunk of a fill), and is_aligned_to checks an address. The simulator keeps the trait’s defaults, the borrowed transfer on the buffer’s bytes, so every fault, hint and oracle applies to the owned form unchanged.
write_at_durable is a write and a data sync in one call. The Tokio backend issues pwritev2(RWF_DSYNC) on Linux and falls back to pwrite plus fdatasync on a kernel that refuses the flag and on other platforms. The simulator models it as exactly write_at then sync_data: the write lands dirty, a hint! (“storage: durable write landed, not synced”) names the window before the sync, and the sync commits it. Sync failures, lying syncs, the barrier-bounded crash model and the lost-synced-write oracle all apply unchanged. The simulated sync is file-wide, which is stronger than the contract: code that relies on a durable write covering an earlier unsynced write passes in simulation and loses data on Linux, so a durable write belongs where its range is the file’s only unsynced write.
Turning Faults On
StorageConfiguration holds the timing and every fault probability. All faults are off by default, and a family at zero draws no randomness. The builder picks a profile per seed:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(3, || Box::new(Replica::new()))
.enable_chaos([Chaos::Storage(ChaosMode::Swarm), Chaos::BuggifyKnobs])
// A phantom write makes a replica forget a vote it cast: out of contract.
.storage_fault_mask(StorageFaultMask::all().without(StorageFault::PhantomWrite))
.chaos_duration(Duration::from_secs(30))
}
ChaosMode::Random uses StorageConfiguration::random_for_seed(): fault rates between 0.001% and 0.1%, IOPS from 10K to 100K, bandwidth from 50 to 500 MB/s. ChaosMode::Swarm then switches each family off with 50% probability. Chaos::BuggifyKnobs spikes a knob on some seeds: IOPS down to 100 to 5,000, sync failures up to 5% to 20%, stalls at 10% to 50%. The StorageFaultMask is applied last and draws nothing, so masked and unmasked seeds sample identically.
StorageFault | Knobs | What it finds |
|---|---|---|
Corruption | read_corruption_probability, write_corruption_probability | Missing checksums. Damage is deterministic: a retry returns the same wrong bytes. |
Eio | read_eio_probability, write_eio_probability | Error paths. An error is not corrupt data, and both halves need driving. |
Misdirect | misdirect_read_probability, misdirect_write_probability | Records without their own address in the checksum. |
PhantomWrite | phantom_write_probability | Firmware that acknowledges a write it never stored. |
SyncFailure | sync_failure_probability | Unchecked fsync results. A failed sync_dir reports storage_sync_fault on the timeline. |
ShortTransfer | short_transfer_probability | Callers that treat a read_at count as “all of it”. Never shortened to zero. |
DirEntryLoss | unsynced_dir_entry_loss_probability | Engines that sync the file and forget the directory. |
CrashDamage | crash_lost_probability, crash_latent_fault_probability, shorn_write_probability | Recovery. See the crash model below. |
Degradation | disk_stall_*, disk_throttle_* | Timeout cascades and backpressure collapse. |
DiskFailure | disk_failure_probability | A missing timeout around I/O. |
DroppedMutationDiscard | dropped_mutation_discard_probability | Nothing by itself. It is the other half of “a dropped write still lands” (below). |
WritebackError | writeback_error_probability, writeback_error_revert_probability, writeback_error_reporting | Code that retries fsync after an error. See failed writeback below. |
Timing, Episodes and Dead Disks
Steady state is a SATA SSD by default: 25,000 IOPS, 150 MB/s, reads at 50 to 200 µs, writes at 100 to 500 µs, syncs at 1 to 5 ms. fast_local() is 1M IOPS, 1 GB/s and 1 µs everywhere, with no faults. Calibration replaces the defaults with your machine’s numbers.
Real disks degrade in episodes, which FoundationDB models with its DiskFailureInjector. A stall freezes a disk for disk_stall_duration. A throttle divides IOPS and bandwidth by the two multipliers for disk_throttle_duration. An episode is keyed by the owning process, not the file, so one garbage-collection pause stalls every file the machine owns at once, and the burst of completions when it lifts is the backpressure spike that overflows real queues.
A failed disk never answers. Every read, write, sync or set_len after the failure stays Pending for the rest of the run, like FoundationDB’s failedDisk. The stall detector then reports a deadlock, which is the verdict: a process that cannot survive a hung disk has a bug. At most one disk is failed at a time, so a quorum never loses two members this way. A crash or wipe of the owner replaces the disk and fails the parked operations with OperationInterrupted. SimWorld::fail_disk_for_process(ip) is the scripted form.
What happens to a write when its future is dropped? Code wraps a write in a timeout or a select! and moves on. On a real backend the write does not stop. Tokio’s spawn_blocking task keeps running, and on io_uring the kernel owns the operation and the runtime holds its buffer until it completes. So the simulator does the same. A dropped write, positioned write, sync or set_len keeps its scheduled completion. The effect lands at that time, with the same fault rolls as an awaited operation, and nobody is woken. Closing a handle with a stream write in flight follows the same rule. A code path that assumes “dropped means not written” fails here, not in production. The dropped_mutation_discard_probability coin, drawn once at drop time, models the write that never reached the device: it is discarded whole. A dropped mutation is never half applied. A crash, a wipe or shutdown fails a dropped mutation still in flight like any other in-flight operation, and a mutation parked on a failed disk never lands. A dropped read is cancelled, because it has no effect to land.
A full disk refuses growth. SimWorld::set_disk_capacity(ip, Some(DiskCapacity::new(bytes))) gives one process’s disk a size: the sum of its files’ lengths, an unlinked file still open included. A write past the end of a file, or a set_len that grows one, fails with io::ErrorKind::StorageFull (ENOSPC) when the growth does not fit, and none of its bytes land. A write inside a file’s length needs no space, so a preallocated file stays writable on a full disk. Deleting or shrinking a file frees space at once. DiskCapacity::amplified(n) counts each byte n times, so an ordinary workload fills a disk during a short run. The capacity is the harness’s choice, not a rate: it draws nothing, holds across a crash or a wipe, and recovery mode lifts every capacity. SystemSample::space reports the disk’s DiskSpace { total_bytes, free_bytes } (statvfs in production), so the code under test can see the disk fill before it refuses.
The Barrier-Bounded Crash Model
A sync is the only barrier a file has. Everything written since the last one is up for grabs.
Every simulated file keeps two images: the durable bytes as of the last successful sync, and the visible bytes reads observe. A write dirties sectors (512 bytes, SECTOR_SIZE) in the visible image. A sync commits them. On a crash each dirty sector resolves independently:
CrashOutcome | The sector afterwards |
|---|---|
KeptOld | Its last durable contents. The unsynced write is gone. |
KeptNew | The unsynced write, intact. |
Lost | The fill pattern: never written. |
LatentFault | The new bytes, but reads return deterministic damage. |
Shorn | A sub-sector mix of old and new bytes (opt-in). |
Three knobs shape the whole crash. clean_crash_probability (10%, FoundationDB’s number) keeps every unsynced write. correlated_rollback_probability (25%) rolls back a contiguous run of up to correlated_rollback_max_run sectors together, the erase-block damage Zheng measured at FAST’13. length_survives_crash_probability (50%) decides whether an unsynced set_len holds. This is what makes “the later record landed while the earlier one is partial” reachable, which is the case journal recovery exists for.
A lost sector reads the file’s fill pattern. garbage_fill_probability draws it per file: zeros on some, garbage on others (50/50 under random_for_seed). Zeros are the dangerous real case (SATA RZAT, NVMe DLFEAT, unwritten extents), so recovery that infers “never written” from “reads as zero” has to be driven red on both.
A reboot runs the crash through SimWorld::simulate_crash_for_process(ip, close_files). Every pending operation of that process completes with an interrupted error. wipe_storage_for_process(ip) deletes everything the process owns, which is what RebootKind::CrashAndWipe does after the crash. Each crash leaves a FileCrashReport (take_storage_crash_reports()), and every injected fault a StorageFaultRecord (take_storage_fault_records()).
Directory Entries Crash Too
create_dir_all("db") // the directory is visible
sync_dir(".") // the db name is durable
open("db/wal", create) // a directory entry
write(..) // the file fills
sync_all(file) // the bytes are durable
After that sequence a crash may still leave no db/wal. The bytes were durable, the name was not. SQLite, PostgreSQL and LMDB follow every create, delete and rename with a directory sync, and sync_dir is that call. The simulator keeps a visible namespace and a durable one. Each divergence resolves independently at crash time under unsynced_dir_entry_loss_probability: a created name may be missing, a deleted one may be back. list_dir shows the visible namespace, which is what readdir sees.
Failed Writeback (fsyncgate)
sync_failure_probability is the kind failure: the sync fails and the sectors stay dirty, so a retry really makes them durable. Linux is not that kind. When the background writeback of a page fails, the kernel marks the page clean and reports the error once. A retried fsync then returns success for bytes that never reached the disk. PostgreSQL found this in 2018 (“fsyncgate”), and Rebello et al. (ATC ’20) found it on ext4, XFS and Btrfs.
With writeback_error_probability > 0, a write can make the background writeback of the file’s dirty sectors fail. These sectors become stranded: no later sync flushes them, but they stay volatile, so a crash can still lose them. A write to a stranded sector dirties it again. writeback_error_revert_probability also drops the new bytes from the cache, so reads return the durable bytes at once. Otherwise reads return the new bytes until a crash. The oracle never stamps a stranded sector, so this loss is not a simulator bug.
writeback_error_reporting picks which handles see the error on their next sync:
WritebackErrorReporting | Kernel | Who sees the error |
|---|---|---|
Errseq (default) | Linux 4.16 and later | Every handle, once. A handle opened later sees it only if no handle saw it before. |
OpenedBefore | Linux 4.13 to 4.15 | Only the handles open before the error, once each. |
FirstCaller | Linux before 4.13 (AS_EIO) | The first sync on any handle. The other handles never see it. |
Silent | some BSD releases | No handle. The sync lies. random_for_seed never draws it. |
The safe answer is the one PostgreSQL chose: treat a sync error as data loss and crash, then recover from the log. Never retry the sync.
The Lost-Synced-Write Oracle
At every sync, each committed sector is stamped with a CRC of the bytes the caller was told are durable. That is FoundationDB’s AsyncFileWriteChecker. After a crash, a stamped sector that no longer matches is a simulator bug, and the run fails with an assertion.
Unless the barrier-violation family is armed. With barrier_violation_probability > 0, a sync occasionally lies about a sector, and the oracle flips to must-detect mode: the loss is recorded as an expected StorageFaultKind::LostSyncedWrite. This family is how a consumer proves that cluster-level recovery heals a single lying disk, the fsyncgate class of bug. SimWorld::corrupt_durable_out_of_band(path, sector) plants a deliberate violation to test the oracle itself.
The stamp covers the synced prefix of a file’s last partial sector. Growing the file makes the rest of the sector addressable and unsynced, and leaves the stamped prefix alone. A shrink inside a sector narrows the stamp to the bytes that survived.
Per-Process Disks
Every file is owned by a process IP, and faults, timing and episodes resolve against the owner’s profile. One node can have a flaky SSD while the rest are healthy:
#![allow(unused)]
fn main() {
let degraded = StorageConfiguration {
read_corruption_probability: 0.01,
..StorageConfiguration::default()
};
sim.set_process_storage_config("10.0.1.2".parse()?, degraded);
}
Random faults are layout-blind: they hit whatever sector an operation touches, and most of a preallocated file is zeros nobody reads. Aiming Storage Faults shows how a format moves the damage onto the bytes recovery has to judge, and how a replicated system keeps one clean copy of every record by construction.
Aiming Storage Faults
- Most of a Disk Is Nobody’s Business
- A Map the Format Draws
- The Eligibility Mask
- Fault Focus
- Targeted Injection
- Replicated Fault Patterns
- Which One to Reach For
Most of a Disk Is Nobody’s Business
Random storage faults are layout-blind. A corruption coin lands on whatever sector an operation touches, and a crash resolves every dirty sector alike. Most of a preallocated journal is zeros that nobody reads. A uniform fault mostly lands there, the run stays green, and the sixty-four bytes that recovery has to judge, the identifiers of the last batch, are never hit.
Moonpool keeps its fault coordinates simple: a file plus a flat sector offset. It does not know what a slot, a page or a header is. The format does. So the format tells the simulator where its bytes live, and three mechanisms use that map: an eligibility mask, a fault focus and a replicated fault pattern. A fourth, targeted injection, needs no map at all.
A Map the Format Draws
LayoutRegion in moonpool-core is plain data: one named byte range of one file.
#![allow(unused)]
fn main() {
pub struct LayoutRegion {
pub path: String, // as the format opens it
pub bytes: Range<u64>,
pub kind: &'static str, // the format's own label: "slot", "header"...
pub stripe: Option<u64>, // the replicated record these bytes hold
}
}
kind lets a harness weigh kinds differently. stripe is the key every replica’s copy of a record shares (a Paxos slot, a page number), or None for bytes only this node has, such as a header. Nothing in it depends on a simulator, so a format lists its regions without a cfg(test).
moonpool-journal is one such format. Journal::regions() returns every region a fault could hit: each live position’s persist record and entry, striped by position, plus the two metainfo copies and every segment’s two header copies with no stripe. Journal::layout(position) returns the Layout of one position. The kinds are Layout::RECORD, Layout::ENTRY, Layout::META and Layout::HEADER. See A Crash-Aware Journal.
The Eligibility Mask
The coarsest control is a veto: (path, sector) -> bool. SimWorld::set_storage_eligibility_mask(mask) installs one, and clear_storage_eligibility_mask() removes it. The random families and the damaging crash outcomes (Lost, LatentFault, Shorn) consult it. An ineligible sector falls back to the always-legal old-or-new resolution.
#![allow(unused)]
fn main() {
// Never damage sector 0 of any file: the superblock stays readable.
sim.set_storage_eligibility_mask(Arc::new(|_path: &str, sector: u64| sector != 0));
}
This is TigerBeetle’s ClusterFaultAtlas pattern, used by a harness that keeps its own ledger. Coins are rolled before the mask is consulted, so installing one never shifts the random stream.
Fault Focus
A mask says whether. A FaultFocus says how likely. It gives a weight to byte ranges of a process’s files and a background weight to every other sector of that disk. Every family rolled per sector or per operation (read and write corruption, read and write EIO, phantom writes, sync lies, and the damaging crash outcomes) has its probability multiplied by the weight of the sectors it would hit:
#![allow(unused)]
fn main() {
// Inside a process: aim this disk's faults at the journal's live regions.
let focus = FaultFocus::new()
.background(0.1) // the zeros, barely
.layout(&journal.regions(), |region| {
if region.kind == Layout::RECORD { 8.0 } else { 4.0 }
});
ctx.storage().focus_faults(focus)?;
}
spot(path, bytes, weight) adds one range by hand. Where spots overlap, the heaviest wins. A weight of 0 makes a sector immune. A weight never revives a family the configuration leaves at zero, and a misdirected write honours only the immunity. The rolls are drawn whatever the weights, so a focus changes which faults fire, never which draws decide them.
SimStorageProvider::focus_faults is reachable from process code and scopes the focus to that process’s disk. clear_fault_focus() removes it. A hand-driven test calls SimWorld::set_fault_focus(ip, focus). A focus survives its process’s crash and is cleared by a wipe of its storage.
Targeted Injection
A directed test does not want probabilities. It wants this sector broken, now. Four SimWorld methods are path-global, reaching every process’s file at that path:
| Method | Effect |
|---|---|
corrupt_file(path, sectors) | Deterministic damage on those sectors |
fail_file_with_eio(path, sectors, target) | Reads, writes or both (EioTarget) on those sectors fail with an I/O error |
clear_file_eio(path, target) | Removes the injection |
corrupt_durable_out_of_band(path, sector) | A deliberate barrier violation, to test the oracle itself |
To damage one copy of one record on one replica, aim at one owner and exact bytes:
| Method | Effect |
|---|---|
corrupt_process_file_bytes(ip, path, bytes) | Durably changes exactly those bytes of that process’s file. Each byte is XORed with a nonzero mask of its offset, so no randomness is drawn. The durability oracle is re-stamped, so a later crash does not report a lost synced write. |
fail_process_file_with_eio(ip, path, sectors, target) | EIO on one owner’s file only |
clear_process_file_eio(ip, path, target) | Removes it |
process_file_durable_bytes(ip, path) | That file’s durable image, to find the bytes to aim at |
A scripted FaultInjector holds the same four on FaultContext: durable_bytes, corrupt_bytes, fail_file_with_eio and clear_file_eio, each taking the process IP as a string. Reading the durable bytes takes no simulated time, rolls no fault coin and draws no randomness, so aiming never perturbs the run:
#![allow(unused)]
fn main() {
// Inside a FaultInjector: damage three bytes of the first replica only.
let first = &ctx.process_ips()[0];
let image = ctx.durable_bytes(first, "data")?;
let record = find_last_record(&image); // the format's own parsing
ctx.corrupt_bytes(first, "data", record.start..record.start + 3)?;
}
The other replicas’ files at "data" are untouched. corrupt_bytes refuses a range past the durable end with StorageError::OutOfRange, and a missing file with NotFound.
Replicated Fault Patterns
A replicated system survives disk damage only while some replica keeps a clean copy of every record. Random faults drawn independently per disk eventually hit every copy of one record, and a valid run becomes unwinnable. A harness could keep a ledger. Moonpool prevents it by construction, because it knows the topology:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.cluster(LocalityConfig::new(1, 3, 1..=2, 1), make_node)
.enable_chaos([Chaos::Storage(ChaosMode::Swarm)])
.replicated_storage_faults(ReplicatedFaults::new(DomainLevel::Zone).group("node"))
}
Each process publishes what its disk holds with ctx.storage().publish_layout(®ions). The stripe of each region is the record’s key, shared by every replica’s copy. A ReplicatedFaults covers one process group (.group(name), the processes’ Process::name), or the whole topology without one. Call replicated_storage_faults once per replicated group, so acceptors and matchmakers each draw their own pattern. Processes outside every covered group keep plain storage chaos.
Each seed draws one FaultPattern per group over the failure domains at the chosen DomainLevel (Machine, Zone or Datacenter):
FaultPattern | Where damage may land |
|---|---|
Minority { domains } | Only the processes of a few domains, anywhere on their disks. TigerBeetle’s minority corruption. |
Striped { domains, local } | Every domain, but stripe s only in the domain it rotates to. TigerBeetle’s helical corruption, keyed by record instead of file offset, because a replicated log writes the same record at different offsets on each replica. |
Rolling { domains } | One domain at a time, anywhere on its disks. The turn moves on once the damage is repaired. |
Spared | The group has fewer than two domains, so no process in it takes damage. |
tolerance(n) (default 1) is how many domains may hold damage to one record at once. It is clamped so one domain always keeps every record. Under a stripe rotation, node-local bytes (stripe None, or bytes never published) are damaged only in the tolerance - 1 local domains, and those domains may damage every stripe. A format may answer a damaged header by giving up the whole replica, so node-local damage counts against every stripe. At the default tolerance no node-local byte is damaged under this pattern.
A rolling turn is repaired when every damaged sector has been rewritten, truncated away or deleted, or is no longer inside a region its process publishes. A domain holding no damage passes the turn as soon as another domain’s disk is up for a fault. A domain that never repairs holds the turn, and a run stuck on one turn is the evidence. SimWorld::fault_turn(ip) names the domains holding the turn, and SimStorageProvider::fault_pattern() tells a process which pattern its group drew. SimWorld::draw_replicated_faults(config, &localities) draws a pattern for a hand-driven test.
Failure domains come from the .cluster() topology. A group registered with .processes() has none and is always spared. The pattern shapes the same families a focus weighs. It is a weight of zero where damage may not land, and it composes with a focus and the mask. Disk failure, wipes and lost directory entries are outside it: they take a whole disk, or only unsynced state.
Which One to Reach For
| You want | Use |
|---|---|
| A sector that must never break | The eligibility mask |
| Random faults where recovery has to look | FaultFocus with the format’s LayoutRegions |
| One record on one replica, broken now | FaultContext::corrupt_bytes after durable_bytes |
| A cluster that stays recoverable under random damage | replicated_storage_faults with publish_layout |
All four compose. The pattern and the mask veto first, the focus weighs what remains, and a targeted injection ignores all three because the test owns its budget.
CPU Time and Gray Failures
- A Poll Should Cost Something
- The CPU Gate
- Charging for Real Work
- Reading the Counters
- Gray Failures
- What the Gate Does Not See
A Poll Should Cost Something
Without a CPU model, a task poll takes no simulated time. Time moves only between events. A process that encodes a megabyte, folds a thousand records and sends the result does all of it at one instant. That hides a whole family of bugs:
- a busy machine answers late, and a peer’s timeout fires first,
- a burst of requests queues behind one core, and the tail latency grows,
- a busyness metric that a load balancer or a leader election reads is always zero, so the code that reads it is never tested.
FoundationDB’s simulator charges a small delay to a task as it runs. Moonpool does the same, with one core per process.
The CPU Gate
The CPU model is a per-seed chaos surface:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(5, || Box::new(Server::new()))
.workload(Client)
.enable_chaos([Chaos::Cpu(ChaosMode::Swarm)])
.chaos_duration(Duration::from_secs(30))
}
ChaosMode::Random turns the model on for every seed. ChaosMode::Swarm turns it on for half of them. A seed with the model off runs exactly as it would without the entry, apart from the draw that decided it. A seed with the model on draws a CpuModel:
| Field | Range | Meaning |
|---|---|---|
poll_cost | 0.5 to 5 µs | One poll of one task of a process |
sent_byte_cost_ps | 0.5 to 5 ns per byte | Each byte the process sends |
written_byte_cost_ps | 0.5 to 5 ns per byte | Each byte the process writes to its disk |
A low-level test sets a model with SimWorld::set_cpu_model(Some(CpuModel::new(..))) and reads the ground truth with SimWorld::cpu_busy(ip). A fault injector reads the seed’s model with FaultContext::cpu_model().
How a poll pays:
- Every process has one CPU slot, free at some instant. Before the executor polls a process’s task, it asks the world. If the slot is busy, the task is parked and a
CpuFreeevent is scheduled for the instant the slot frees. The stall detector counts that event as progress. - When the poll starts, the process’s local clock is
now + poll_cost × slowness. Every byte sent or written, and every explicitcpu_charge, moves the local clock on. When the poll ends, the slot is free at the local clock. - While a poll runs, the process sees its local clock.
time().now(), a timer, a storage submit and a sent chunk all happen after the work. - The network engine keeps one clock for both ends of a connection. The world shifts every network event a poll schedules by
local − now, and a sent chunk leaves no earlier than its writer’s local clock.
Tasks without an owner (the workload, a fault injector, the driver) are never gated and cost nothing. Network and storage completions are world events, not tasks, so they cost nothing either. Tasks a process spawns inherit its owner, so moonpool’s own RPC tasks pay: they are shipped code, and they use the machine’s CPU in production too.
A kill or a crash drops the parked tasks of the process and frees its slot. The new incarnation starts on an idle core.
Charging for Real Work
A poll costs a small base amount. The explicit charges carry the signal. moonpool_buggify::cpu_charge lets shipped code say it just did a known amount of work:
#![allow(unused)]
fn main() {
fn fold_batch(records: &[Vec<u8>]) {
// ... the real work ...
moonpool_buggify::cpu_charge(Duration::from_nanos(200) * records.len() as u32);
}
}
Put a charge only on a hot path a profile shows: an encode or decode of a batch, a checksum, a fold. Like hint!, a charge is inert outside a simulation. No sink is installed, so the call costs one thread-local read and does nothing. The simulator installs its sink only while its CPU model is on, and then the calling process’s clock moves forward by the cost times its slowness. The same code runs in production and in simulation. See Same Code in Production and Simulation.
Reading the Counters
SystemProvider is the sixth provider. It reads cumulative CPU and disk counters the way the kernel exposes them (getrusage, /proc/stat, /proc/diskstats) and does no arithmetic. A caller takes two SystemSamples and divides the differences by the elapsed time, as FoundationDB’s getSystemStatistics does:
#![allow(unused)]
fn main() {
// Inside a process: sample every 100 ms, as a load reporter would.
if let Ok(sample) = ctx.providers().system().sample("") {
samples.push(sample);
}
}
A SystemSample carries at, process_cpu, cores, machine_busy, machine_total, run_loop_busy, disk: Option<DiskCounters> and space: Option<DiskSpace>. In production, TokioSystemProvider and CompioSystemProvider read the kernel. In simulation, with the model on, process_cpu, machine_busy and run_loop_busy are the time the core was busy up to the sample’s instant, never past it, so process_cpu ≤ at always holds. One process per machine and one run loop per process make the three equal. With the model off they stay at zero. The disk counters are the simulated disk’s own: io_ticks is the time with at least one operation in flight, so a stall or throttle episode makes it rise. FoundationDB turns its system monitor off in simulation. Moonpool does not, so the code that reads the counters runs under simulation too.
A workload checks what a process reported against the samples it was really given with SimContext::system_samples(ip) and SimContext::disk_counters(ip).
Gray Failures
A crash is easy to see: the peer times out and moves on. A slow machine is harder. It answers, late. Its heartbeats arrive, a leader on it keeps its lease, and it drags the cluster down. Chaos::GrayFailure slows one or more machines for a long window and keeps them alive:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(5, || Box::new(Acceptor::new()))
.enable_chaos([
Chaos::Cpu(ChaosMode::Swarm),
Chaos::GrayFailure {
config: GrayFailure::new(2).victims(AttritionVictims::group("acceptor")),
mode: ChaosMode::Swarm,
},
])
.chaos_duration(Duration::from_secs(30))
}
When the chaos window opens, the seed draws how many machines are slow (one to max_slow, among the processes victims admits), which ones, and for each one a window and three factors:
Slowness field | Default range | What is multiplied |
|---|---|---|
cpu | 10 to 1000 | Every CPU cost of the process. Only on seeds with the CPU model on: a gray failure never turns the model on. |
disk | 5 to 100 | Every transfer and sync latency of the machine’s disk |
network | 2 to 20 | The latency of every chunk sent to or from the machine (the larger factor of the two ends) |
Each window is 30% to 100% of the chaos window, placed at a random start inside it. GrayFailure::new(max_slow) sets the defaults, and the window, cpu, disk, network and kinds fields are public. max_slow is the budget: keep it at the number of machines your system tolerates losing, so a quorum can still form. ChaosMode::Swarm masks each kind per seed, so some seeds slow only the disk and some only the network. The reach gates gray: cpu slow, gray: disk slow and gray: network slow show which kinds a campaign reached. A GrayFailure that names an unregistered group fails the run with InvalidConfiguration.
Recovery mode makes every machine healthy again, so the quiet tail runs at full speed.
A custom injector does the same by hand with FaultContext::set_slowness(ip, Slowness { cpu, disk, network }) and reads it back with FaultContext::slowness(ip). Slowness::HEALTHY is all ones. A workload reads a machine’s factors with SimContext::slowness(ip), for example to check that a slow process reports itself as slow:
#![allow(unused)]
fn main() {
// Inside a workload: a slow acceptor must not hold the lease.
if !ctx.slowness(acceptor_ip).is_healthy() {
assert_sometimes!(leader != acceptor_ip, "a slow machine gave up the lease");
}
}
What the Gate Does Not See
The gate stamps everything that goes through the world: timers, storage operations, network events, sent chunks and the wakes they cause. A few paths reach another owner directly. Each is bounded by one poll’s CPU time, or is not part of the system under test:
| Path | Why it is left alone |
|---|---|
| Connection state the peer sees at once (an abort, a read-window credit, a listener bound or unbound) | The state is shared, but the wake that follows is stamped. The peer can see the change at most one poll’s CPU early. |
StateHandle | A harness channel between processes and the workload, not a production path. |
A JoinHandle completion, an in-process channel between two owners | Harness code that shares memory across machines. moonpool-rpc keeps its channels per process. |
| Tracing events | Stamped with the scheduler’s time, not the local clock. |
Harness readers (SimWorld::cpu_busy, disk_counters from a workload) | Ground truth, read at the scheduler’s time. |
If your harness shares state between processes through an Arc<Mutex<..>>, it bypasses the gate the same way. Keep such boards for observation only.
Custom Fault Injectors
- When the Built-Ins Are Not Enough
- Registering a Factory
- The FaultContext
- Scripted Lifecycle Faults
- Publishing Vetoes and Reading Landings
- Rules for an Injector
When the Built-Ins Are Not Enough
Attrition, outages, network and storage chaos are sampled per seed. They are the right tool for “break things at random and see what survives”. A scripted sequence is a different need: crash node X, let the workload make progress, bring X back, and check that X catches up. Or: wait until every replica has written its segment, then corrupt three bytes on one of them. For that you implement FaultInjector.
#![allow(unused)]
fn main() {
use std::time::Duration;
use async_trait::async_trait;
use moonpool_sim::{FaultContext, FaultInjector, RebootKind, SimulationError, SimulationResult, TimeProvider};
struct RollingRestart;
#[async_trait]
impl FaultInjector for RollingRestart {
fn name(&self) -> &str { "rolling_restart" }
async fn inject(&mut self, ctx: &FaultContext) -> SimulationResult<()> {
// Restart each process in order, with time to recover between each.
for ip in ctx.process_ips() {
ctx.reboot(&ip, RebootKind::Graceful)?;
ctx.time().sleep(Duration::from_secs(15)).await
.map_err(|e| SimulationError::InvalidState(e.to_string()))?;
if ctx.chaos_shutdown().is_cancelled() {
break;
}
}
Ok(())
}
}
}
name() labels the injector in the report. inject() runs as one task for the chaos window. It must watch ctx.chaos_shutdown() and return once the token is cancelled.
Registering a Factory
An injector is registered through a factory, never as an instance:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(3, || Box::new(MyProcess))
.workload_factory(|| Box::new(MyWorkload))
.fault_factory(|| Box::new(RollingRestart))
.chaos_duration(Duration::from_secs(30))
}
The runner builds a fresh injector from the factory for every root seed and every explored continuation. A mutated instance could not be rewound for a second timeline, so scripted sequences replay exactly from the seed plus recipe, with in-process exploration and forked workers alike. An injector that must report back (an observation log) captures an Arc in the closure.
A fault factory requires chaos_duration. run() returns SimulationError::InvalidConfiguration for a builder that registers one without it. Several factories run side by side, together with the built-in injectors enable_chaos adds.
The FaultContext
FaultContext is what an injector sees. Unlike SimContext, it exposes the fault primitives a workload must not touch.
| Method | What it does |
|---|---|
process_ips() | Every live server process IP, read now (spawned included, retired excluded). ips_in_group(name) narrows to one group, ascending. |
group_registry(), machine_registry() | Copies of the live group and failure-domain registries. membership() and membership_version() read the live set itself. |
time(), random() | The simulated time and the seeded random provider. |
state() | The per-iteration StateHandle workloads and processes share. |
chaos_shutdown() | The token cancelled when the chaos window closes. |
reboot(ip, kind) | Kill and restart after a drawn delay. reboot_with_delays takes the delay ranges. reboot_random, reboot_tagged, reboot_machine and reboot_domain pick the victims. |
crash(ip) | Force-kill with no restart scheduled. The node stays down until restart. |
crash_for(ip, down) | Force-kill and restart after exactly down. No draw. |
restart(ip) | Boot a fresh instance. On a live process it is an in-place restart. |
dead_count(), is_dead(ip) | The budget every injector shares. |
partition(a, b), heal_partition(a, b), is_partitioned(a, b) | A bidirectional partition that holds until healed. |
set_slowness(ip, s), slowness(ip), cpu_model() | A gray failure by hand. |
set_disk_capacity(ip, Some(DiskCapacity)), disk_space(ip) | A disk that fills up: growth past the capacity fails with ENOSPC. None lifts it. |
durable_bytes(ip, path) | The durable bytes of one process’s file, to read a layout before aiming. No draw, no simulated time. |
corrupt_bytes(ip, path, range) | Mutate exactly those bytes, durably, on that process’s disk alone. |
fail_file_with_eio(ip, path, sectors, target), clear_file_eio(ip, path, target) | EIO on reads, writes or both, for a sector range of one file. |
The storage methods are the subject of Aiming Storage Faults. The reboot methods follow the rules in Attrition: a process counts as dead from the decision, a dead process is skipped, and a kill lands one scheduler tick after the call.
Scripted Lifecycle Faults
reboot() combines a kill with a timer-based restart, which is right for random attrition and wrong for a script. crash and restart split the two, and state() lets the script wait on a workload milestone instead of a wall of sleeps:
#![allow(unused)]
fn main() {
async fn inject(&mut self, ctx: &FaultContext) -> SimulationResult<()> {
let target = ctx.process_ips()[0].clone();
ctx.crash(&target)?; // crash now, hold down
// Wait until the workload has executed 100 operations.
while ctx.state().get::<u64>("ops").unwrap_or(0) < 100 {
ctx.time().sleep(Duration::from_millis(20)).await
.map_err(|e| SimulationError::InvalidState(e.to_string()))?;
if ctx.chaos_shutdown().is_cancelled() { return Ok(()); }
}
ctx.restart(&target)?; // explicit recovery
Ok(())
}
}
Everything the script reads is per-iteration simulated state, so the crash target, the milestone and the restart point replay from the seed. crates/moonpool-sim/tests/scripted_faults.rs is the full version: it checks that a held-down process runs no application work until the restart.
restart on a process that is still running is an in-place restart. The old boot is killed like a force kill: its task, the tasks it spawned and its connections, which peers see aborted. Storage is left alone, as when a process exits on its own. The fresh boot starts one scheduler tick later, after the executor has dropped the old one, so two boots of one process never overlap. The kill is recorded in the fault timeline as a process_force_kill with cause restart_in_place.
Publishing Vetoes and Reading Landings
An injector is the natural home for the harness’s budgets. At the start of the window it can publish a HintVeto or an OutageVeto in ctx.state(), and the built-in injectors ask them before they strike. It can also wait on what the built-ins publish: an OutageLanded under OUTAGE_STATE_KEY marks the instant every memory of a group is gone, the right moment to plant latent disk damage. hint! and Correlated Outages show both vetoes.
Rules for an Injector
- Draw randomness only from
ctx.random()or thesim_random_*functions. Never fromrand::thread_rng()or the wall clock. - Sleep only through
ctx.time(). A real sleep stalls the executor. - Check
ctx.chaos_shutdown()in every loop and after every sleep, then return. An injector that keeps injecting after the window keeps the quiet tail from being quiet. - Keep the draw count independent of the outcome. A resample loop makes the schedule depend on what it found, and an exploration recipe keyed on the draw count stops replaying.
- Do not keep state across seeds. The factory builds a fresh injector for each one. Share observations through an
Arcthe factory captures. - Record what the script did with
assert_reachable!andassert_sometimes!, so the report shows the scenario ran. An injector that was never reached is a scenario that was never tested.
crates/moonpool-rpc-sim is a larger example: each campaign ships an injector that scripts DNS changes, lost replies, one-way partitions and crashes against a named server group, and coordinates with its workload through state().
Assertions: Finding Bugs
- Why Assertions Never Crash
- Two Jobs for One Line
- The Fifteen Macros
- Where the Counts Live
- The Reporting Pipeline
- Assertions Decide When to Stop
- Probes in Shipped Code
A simulation catches a process in a bad state. We have two choices: stop the run, or record the fact and keep going. Traditional tests stop. Moonpool records and continues.
Why Assertions Never Crash
The first thing that goes wrong is rarely the worst thing that goes wrong. If a replica commits a stale value and the run aborts there, we never learn what the replication protocol does with it. Maybe two other nodes accept the stale value. Maybe the system looks healthy for another thousand steps before a user reads garbage. An early abort hides that cascade, and the cascade is where the damage is.
So moonpool follows the Antithesis rule: assertions record violations and continue. assert_always! with a false condition does three things:
- It increments the fail counter of its slot in the assertion table.
- It prints
[ASSERTION FAILED] <message>to stderr the first time that slot fails. - It sets a thread-local flag that marks the current seed as failed.
The run then goes on. More assertions fire, more facts are recorded. When the iteration ends, the runner reads the flag (has_always_violations()), counts the seed in failed_runs and seeds_failing, and resets the flag for the next seed. Nothing panics.
Two Jobs for One Line
Every assertion does two jobs.
It checks correctness. The table keeps a pass count and a fail count per assertion, so a report says “failed 3 times out of 41,200”, not just “failed”. That ratio tells you how hard the bug is to hit.
It guides the search. When a sometimes-type assertion passes for the first time, or a watermark improves, the accounting layer calls a discovery hook. Without exploration the hook is a no-op. With exploration enabled, moonpool-explorer records the RNG position of the discovery and forks new timelines from it. The same assert_sometimes! that proves a path was covered also tells the explorer “branch here”.
You write the assertion once, for correctness. The framework gets the guidance for free.
The Fifteen Macros
moonpool_sim exports fifteen assertion macros. They fall into four families.
| Family | Macros | Chapter |
|---|---|---|
| Boolean | assert_always!, assert_always_or_unreachable!, assert_sometimes!, assert_reachable!, assert_unreachable! | Always and Sometimes |
| Numeric, always | assert_always_greater_than!, assert_always_greater_than_or_equal_to!, assert_always_less_than!, assert_always_less_than_or_equal_to! | Numeric Assertions |
| Numeric, sometimes | assert_sometimes_greater_than!, assert_sometimes_greater_than_or_equal_to!, assert_sometimes_less_than!, assert_sometimes_less_than_or_equal_to! | Numeric Assertions |
| Compound | assert_sometimes_all!, assert_sometimes_each! | Compound Assertions |
The next chapter sorts the same fifteen by purpose: invariant, discovery or guidance. The Assertion Reference has every signature in one table.
Where the Counts Live
The macros in moonpool_sim are thin wrappers. The accounting is moonpool-assertions, a crate with zero dependencies that compiles to wasm. It keeps two fixed-size tables:
- The slot table: 2048 slots, one per assertion site. A slot’s identity is the 64-bit FNV-1a hash of its message. Each slot holds the kind, the pass and fail counts, a numeric watermark, and the frontier of a compound assertion. The message itself is stored truncated to 64 bytes.
- The bucket table: 256 buckets for
assert_sometimes_each!, keyed by message plus identity keys.
By default both tables live on the heap of the test process. Under exploration, moonpool-explorer replaces them with MAP_SHARED memory, so forked worker processes write into the same counts. The macros behave the same either way.
Three rules follow from “the message is the identity”:
- Use a constant message.
assert_always!(ok, format!("slot {slot}"))compiles, but every distinct string claims a new slot. Keepformat!for a failure branch that fires rarely, as the leader-election test does, and never put it on a hot path. - One message, one kind. An
assert_always!and anassert_sometimes!that share a message would evaluate one site under the other’s contract. The table flags the slot as conflicted instead, and the run fails with “assertion ‘…’ is shared by call sites of different kinds”. - Never let the table overflow silently. An evaluation that finds the table full is counted.
SimulationReport::dropped_assertion_allocationsreports the count, andis_failure()returns true when it is non-zero. A full table fails loudly instead of looking green.
The counts accumulate across seeds. reset_assertion_results() zeroes them once at the start of run(), not between seeds, so a sometimes that fired in seed 7 still counts as reached in seed 300.
The Reporting Pipeline
When the last seed ends, the runner calls validate_assertion_contracts(). It returns two lists.
Always violations are definite bugs, valid at any iteration count:
- an
always-type assertion failed at least once, - an
assert_always!was never reached (amust_hitsite with zero evaluations), - an
assert_unreachable!was reached, - the slot or bucket table dropped evaluations, or two sites collided.
Coverage violations are statistical and only mean something after enough seeds:
- an
assert_sometimes!or numericsometimeswas evaluated but never passed, - an
assert_reachable!was never reached, - an
assert_sometimes_all!never had every proposition true at once.
Both lists are on SimulationReport as assertion_violations and coverage_violations. assertion_results maps every message to an AssertionStats { total_checks, successes } with a success_rate(), and assertion_details carries the per-slot breakdown the report prints:
━━━ Assertions (4) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ always "dungeon bug: treasure found on the ..." 1,000 pass 0 fail
✓ sometimes "near treasure" 412 / 1,000 (41.2%)
○ sometimes "stairs without key" 0 / 1,000 (0.0%)
✓ numeric "dungeon level reached" 1,000 pass 0 fail best: 6
━━━ Coverage Gaps ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
○ assert_sometimes!('stairs without key') has 0% success rate (1000 checks)
✓ is pass, ✗ is a contract failure, ○ is a coverage miss. report.is_failure() is true for failed seeds, always violations and dropped allocations. It is not true for coverage gaps: a CI job that wants coverage enforced checks coverage_violations and convergence_timeout as well.
Assertions Decide When to Stop
By default a SimulationBuilder runs with IterationControl::UntilCoverageStable { plateau_seeds: 10, max_iterations: 1000 }. The assertions are the stop condition. After every seed the runner scans the slot table and asks two questions:
- Has every observed
sometimes, numericsometimes,reachableandsometimes_allsite been satisfied at least once? - Has the progress signal stayed flat for
plateau_seedsconsecutive seeds?
The progress signal is real LLVM sancov edge coverage when the binary is instrumented, which is what cargo xtask sim run builds. Under plain cargo nextest it falls back to the number of distinct reached coverage sites. Both signals only grow, so “unchanged for 10 seeds” means the run stopped finding new states.
When both answers are yes, the run is saturated and stops early. The report records the outcome in SimulationReport::saturation (sometimes_hit, sometimes_total, the signal, edge counts). If max_iterations arrives first, convergence_timeout is set and the report prints Saturation FAILED.
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.workload(ThreeAlwaysHitWorkload)
// Stop after 3 quiet seeds, never run more than 100.
.until_coverage_stable(3, 100)
.run()?;
}
A rare assert_sometimes! keeps the run alive until it fires. A workload with no coverage assertion at all saturates on the plateau alone, with a warning, because there is nothing left to reach. set_iterations(n) switches to FixedCount and ignores all of this, which is what you want when reproducing one seed.
Probes in Shipped Code
Shipped code can mark a path too. moonpool_assertions::reachable!("label") and moonpool_assertions::sometimes!(cond, "label") are the production forms of assert_reachable! and assert_sometimes!, like FoundationDB’s CODE_PROBE. Outside a simulation no table is installed and each call is one pointer read. Inside one, the probe shares a slot with a simulation assertion of the same message. Never reword a probe’s message: the hash is its identity across campaigns.
Invariants vs Discovery vs Guidance
- Invariants: What Must Always Hold
- Discovery: What Must Happen at Least Once
- Guidance: What to Push Toward
- What Each Kind Does
- Picking the Kind
Not every assertion asks the same question. Some say “this must always be true”. Some say “this must happen at least once”. A few say “try to make this number better”. The runner, the stop condition and the explorer treat the three kinds differently, so the kind you pick decides what a violation means.
Invariants: What Must Always Hold
An invariant must be true every time it is evaluated. One failure is a bug.
#![allow(unused)]
fn main() {
// From the leader-election test: one leader per term, or split-brain.
assert_always!(
prior == &leader,
format!("split-brain at term {term}: {prior} vs {leader}")
);
}
Six macros are invariants: assert_always!, assert_always_or_unreachable!, assert_unreachable! and the four assert_always_* numeric comparisons. In Antithesis’s own Pangolin database about 90% of assertions are of this kind, and that ratio holds in moonpool’s examples too. Most of what we know about a system is “this property holds”.
Invariants do not talk to the explorer. A failure records a violation and marks the seed failed. A pass records nothing interesting. The numeric forms keep a watermark for the report, but a new best value is not a discovery and does not fork a timeline.
There is one trap. assert_always! fails when it is never reached across the whole run. An invariant that guards a recovery path your simulation never enters tells you nothing, so the contract check flags it: assert_always!('...') was never reached. For a path that may legitimately stay cold under some configurations, use assert_always_or_unreachable!.
Discovery: What Must Happen at Least Once
A discovery assertion must pass at least once across all seeds. It may fail a thousand times before that.
#![allow(unused)]
fn main() {
// From the coverage test: fires in roughly one seed out of five.
let rare = moonpool_sim::sim_random_range(0u32..5) == 0;
assert_sometimes!(rare, "rare gate");
// From the runner's own hint code: a path, no condition.
assert_reachable!("hint: a process rebooted at its own hint");
}
Discovery assertions prove coverage. Did a failover happen? Did the retry path run? Without them a simulation can run a thousand seeds down the happy path and report zero failures. Green, and meaningless.
They have three effects:
- Contract. A
sometimesthat was evaluated but never passed, or areachablenever reached, is a coverage violation in the report. - Stop condition. Under
UntilCoverageStable, the run cannot saturate until every observed discovery assertion has passed. A rare gate keeps the campaign alive. - Fork point. The first pass flips an atomic latch in the slot and calls the discovery hook with
DiscoveryKind::SometimesPass. Under exploration, the explorer records the RNG call count and forks continuations from that moment.
The third effect is why discovery assertions have superpowers. Suppose a bug needs a failover (1 in 1,000) and then a specific timing during recovery (1 in 1,000). Random seeds need about a million runs. With assert_sometimes!(failed_over, ...), the explorer finds the failover in about a thousand runs, keeps its replay recipe, and tries a thousand continuations from there. Multiplicative odds become additive. Multiverse Exploration covers the mechanics.
Guidance: What to Push Toward
A guidance assertion names a direction. It is not satisfied once and done. Every improvement is a new discovery.
#![allow(unused)]
fn main() {
// From the dungeon example: deeper is better, and the explorer knows it.
assert_sometimes_greater_than!(i64::from(level), 0, "dungeon level reached");
// Every floor, and every floor reached with good health.
assert_sometimes_each!(
"stairs with key",
[("floor", i64::from(level))],
[("health", i64::from(hp_bucket))]
);
}
Three families guide:
- The four
assert_sometimes_*numeric comparisons track a watermark. A better comparison distance (left - right) is aWatermarkImprovementdiscovery. assert_sometimes_all!tracks a frontier: the most propositions true at the same time. A higher count is aFrontierAdvance; a new partial combination at the same count is aBooleanCombination.assert_sometimes_each!tracks buckets: one per distinct identity-key tuple. A new bucket is aBucketFirst; a better quality score in a known bucket is aBucketQuality.
Each discovery is latched with an atomic compare-and-swap in the shared table, so across any number of forked workers it fires exactly once. The explorer ranks them: watermark and frontier improvements are progress, first passes and new buckets are coverage. That ratchet drives the search toward boundaries, which is where bugs sit.
What Each Kind Does
| Kind | Macros | Seed fails when | Coverage violation when | UntilCoverageStable waits for it | Explorer forks on |
|---|---|---|---|---|---|
| Invariant | assert_always!, assert_always_or_unreachable!, assert_unreachable!, assert_always_* numeric | condition false, or unreachable reached | never | no | nothing |
| Discovery | assert_sometimes!, assert_reachable! | never | never passed | yes | first pass |
| Guidance | assert_sometimes_* numeric, assert_sometimes_all!, assert_sometimes_each! | never | numeric never passed, frontier never full | numeric and sometimes_all: yes. sometimes_each: no | every improvement, new combination or new bucket |
Two cells deserve a second look. assert_sometimes_each! has no contract: a bucket you never hit cannot be listed, because the table does not know it exists. The report lists the buckets found, and that is the coverage statement. And the must_hit flag that makes a cold assert_always! fail is set only on assert_always! itself, not on its _or_unreachable twin.
Picking the Kind
Ask one question per line:
- Must this never be false? Invariant. Add
_or_unreachableif the path may stay cold. - Must this happen at least once? Discovery. Use
assert_reachable!when there is no condition. - Is there a number, a set of flags or a space of values to push through? Guidance.
A good workload has many invariants, a handful of discoveries on its error and recovery paths, and one or two guidance assertions that give the explorer a direction. The next three chapters show each family in detail.
Always and Sometimes
- assert_always!
- assert_always_or_unreachable!
- assert_sometimes!
- assert_reachable!
- assert_unreachable!
- Choosing
Five boolean macros are the base of the assertion system. Each takes a message that identifies the site across seeds and across forked timelines. Three of them accept an optional block of details for the failure line.
assert_always!
The workhorse. The condition must hold every time the line runs.
#![allow(unused)]
fn main() {
// From the metered key/value example: a gauge that went negative is a bug
// in the admission path, not in the metric.
assert_always!(
metrics.in_flight.get() >= 0.0,
"in-flight gauge never goes negative"
);
}
When the condition is false, the slot’s fail count goes up, the seed is marked failed, and the run continues. The first failure of a slot prints [ASSERTION FAILED] <message> (kind=Always) to stderr. Later failures of the same slot only count.
A details block turns every failure into a line with the seed and the values you name:
#![allow(unused)]
fn main() {
assert_always!(
executed <= self.permitted,
"a balanced job never runs more often than its permissions allow",
{ "id" => self.id, "executed" => executed, "permitted" => self.permitted }
);
}
[ASSERTION FAILED] a balanced job never runs more often than its permissions allow (seed=9182) | id=4, executed=3, permitted=2
Every value must implement Display. The details are printed, never stored: the table keeps counts, not strings.
assert_always! also fails when it is never reached in the whole run. The report says assert_always!('...') was never reached. The reason is false confidence: an invariant on a recovery path that recovery never enters has checked nothing. Fix the simulation so the path runs, or switch to the next macro.
assert_always_or_unreachable!
The same contract, minus the reach requirement.
#![allow(unused)]
fn main() {
assert_always_or_unreachable!(
balance >= 0,
"balance stays non-negative after a withdrawal",
{ "balance" => balance }
);
}
If no seed ever withdraws, the slot stays empty and the report says nothing. If a withdrawal does happen and the balance goes negative, that is a violation like any other. The report prints its kind as always?.
Use it for paths that a given chaos configuration may legitimately skip. Reserve plain assert_always! for the paths you insist on testing. Mixing them up produces a flood of “never reached” violations the first time you narrow a swarm mask.
assert_sometimes!
The condition must be true at least once across all seeds. Any single evaluation may be false.
#![allow(unused)]
fn main() {
// From the metered key/value workload's check(): did the time series
// actually record latency observations in at least one seed?
assert_sometimes!(
series.keys().any(|k| k.starts_with("kv_request_latency")),
"latency observations are recorded as a time series"
);
}
Three things happen on the first pass of a sometimes slot:
- The pass count goes from 0 to 1, which clears the coverage violation.
- The slot now counts as reached for
UntilCoverageStable. - The discovery hook fires once, and the explorer, if present, forks from this RNG position.
If the slot is evaluated and never passes, the report lists assert_sometimes!('...') has 0% success rate (N checks) under Coverage Gaps. A sometimes that is never evaluated at all is not listed, because the table never saw it.
assert_sometimes! has no details block. Its message is &$message, so a &str or a String both work, but remember that each distinct string is a separate slot.
A practical pattern from the dungeon example: name the thing, not the condition.
#![allow(unused)]
fn main() {
if self.game.player_tile() == Tile::Treasure {
assert_sometimes!(true, "near treasure");
}
}
assert_sometimes!(true, ...) inside an if is the same as assert_reachable!, and sometimes reads better when the label describes a game state rather than a code path.
assert_reachable!
A code path that must run at least once. No condition.
#![allow(unused)]
fn main() {
// From the runner's reboot code: both reboot shapes must be exercised.
match kind {
RebootKind::Graceful => {
assert_reachable!("reboot: graceful path");
// ...
}
RebootKind::Crash | RebootKind::CrashAndWipe => {
assert_reachable!("reboot: crash path");
// ...
}
}
}
The slot is a Reachable kind with the condition fixed to true. Its first reach is a SometimesPass discovery, and an unreached site is reported as assert_reachable!('...') was never reached. Moonpool’s own runner uses it to prove that its fault paths ran: hint: the harness's veto refuses a reboot, reboot: target already dead, skipped, and so on. Shipped code uses the inert twin moonpool_assertions::reachable! for the same purpose.
assert_unreachable!
The inverse: a path that must never run.
#![allow(unused)]
fn main() {
fn handle(&mut self, msg: Message) -> Result<(), Error> {
match msg.kind {
MessageKind::Request => { /* ... */ }
MessageKind::Response => { /* ... */ }
MessageKind::Unknown => {
assert_unreachable!("received an unknown message kind", { "from" => msg.from });
return Err(Error::UnknownMessage);
}
}
Ok(())
}
}
Reaching the line is the violation. The slot records a pass per reach (the count of “times reached”), the seed is marked failed, and the first reach prints [UNREACHABLE REACHED] <message>. The report says assert_unreachable!('...') was reached N times.
The code after the macro still runs. Moonpool does not throw. That is deliberate: the run goes on to show what the system does after the impossible happened.
Choosing
| You want to say | Macro |
|---|---|
| Always true, and this path must be tested | assert_always! |
| Always true, but the path may stay cold | assert_always_or_unreachable! |
| True at least once | assert_sometimes! |
| This path runs at least once | assert_reachable! |
| This path never runs | assert_unreachable! |
Start with invariants on the core properties. Then add sometimes and reachable on the error branches, the retries and the recovery code. Those are the lines that keep UntilCoverageStable running until chaos has actually visited them, and the lines the explorer forks from.
Numeric Assertions
- The Eight Macros
- Always: Bounds That Never Break
- Sometimes: Goals That Ratchet
- Where the Watermark Lives
- Use Cases
Boolean assertions answer yes or no. Many properties of a distributed system are numbers: queue depth stays under a bound, a cluster reaches a level, latency drops below a target. Eight macros compare two integers. Four are invariants. Four are guidance, and they give the explorer a value to improve.
The Eight Macros
| Macro | Checks | Kind | Watermark |
|---|---|---|---|
assert_always_greater_than!(v, t, msg) | v > t | invariant | lowest v |
assert_always_greater_than_or_equal_to!(v, t, msg) | v >= t | invariant | lowest v |
assert_always_less_than!(v, t, msg) | v < t | invariant | highest v |
assert_always_less_than_or_equal_to!(v, t, msg) | v <= t | invariant | highest v |
assert_sometimes_greater_than!(v, t, msg) | v > t | guidance | highest v |
assert_sometimes_greater_than_or_equal_to!(v, t, msg) | v >= t | guidance | highest v |
assert_sometimes_less_than!(v, t, msg) | v < t | guidance | lowest v |
assert_sometimes_less_than_or_equal_to!(v, t, msg) | v <= t | guidance | lowest v |
Both operands go through to_i64_saturating. Any integer type with TryInto<i64> works, so a u8, a usize or a u64 needs no cast. A value that does not fit becomes i64::MAX. Floats do not convert: scale a latency to milliseconds or microseconds first.
Always: Bounds That Never Break
The four assert_always_* macros are invariants with a comparison instead of a bool.
#![allow(unused)]
fn main() {
assert_always_less_than_or_equal_to!(
queue.len(),
MAX_QUEUE,
"the request queue never exceeds its bound",
{ "ip" => ctx.my_ip(), "depth" => queue.len() }
);
}
A failed comparison records a fail, marks the seed failed, and the run continues. The first failure of a slot prints [NUMERIC ASSERTION FAILED] <msg> (left=.., right=.., cmp=Le). With a details block, every failure prints the operands, the seed and your values. The report lists the slot under num-always with N pass M fail best: W.
best is the watermark: the most extreme value of the left operand seen in the whole run. For a less_than bound it is the highest value, for a greater_than bound the lowest. Even when the bound never breaks, the watermark tells you how close the system came. A queue bound of 1,000 with best: 998 is a different story from best: 12.
The watermark is for the report only. An always-numeric assertion never calls the discovery hook. It does not fork timelines and does not steer the explorer. If you want the explorer to push a value toward a bound, add a sometimes twin that names the direction.
Unlike assert_always!, a numeric invariant that is never reached is not a violation. The contract check only inspects its fail count.
Sometimes: Goals That Ratchet
The four assert_sometimes_* macros are guidance. The comparison must hold at least once across all seeds, and every improvement is a discovery.
#![allow(unused)]
fn main() {
// From the dungeon example: push deeper, and arrive with health left.
assert_sometimes_greater_than!(
i64::from(self.game.status().level),
0,
"dungeon level reached"
);
assert_sometimes_greater_than!(i64::from(hp_after), 0, "health remaining");
}
Each evaluation records a pass or a fail and updates two watermarks in the slot:
- The reporting watermark is the best left operand, shown as
best:in the report. - The discovery watermark is the best comparison distance,
left - right. Agreater_thangoal maximizes it, aless_thangoal minimizes it.
When the discovery watermark improves, the slot calls the hook with DiscoveryKind::WatermarkImprovement. Under exploration that is a fork point. The first improvement happens on the first evaluation, so the explorer branches from level 1, then from level 2, then from level 3. Each better state is a new base for continuations. The watermark only moves one way.
Why track the distance and not the left value? Because the threshold can move during a run. If the right operand grows faster than the left, a larger left value is not progress toward left > right. Antithesis made the same choice. The report still prints the plain left value because that is the number a human wants to read.
Two contract rules apply:
- A slot that was evaluated but never passed is a coverage violation:
numeric assert_sometimes ('...') has 0% success rate (N checks). - The slot counts for
UntilCoverageStable. The coverage testNeverHitNumericGatepins this:assert_sometimes_greater_than!(0, 1, "numeric gate")keeps the run from saturating until the cap.
The sometimes numeric macros take no details block. There is no failure to describe: a miss is expected.
Where the Watermark Lives
The watermarks sit in the slot, in the shared assertion region. Under exploration every forked worker reads the same current best, and a fetch_max or fetch_min updates it atomically. One worker that reaches level 4 raises the bar for every later worker. The search is collective, not one timeline at a time.
Across seeds without exploration the effect is the same but slower: the table is not reset between seeds, so seed 300 is measured against the best of seeds 1 to 299.
Use Cases
A resource bound, with a twin that seeks it.
#![allow(unused)]
fn main() {
assert_always_less_than!(bytes_in_flight, BUDGET, "memory stays within budget");
// Same value, opposite direction: the explorer now hunts for high memory.
assert_sometimes_greater_than!(bytes_in_flight, BUDGET / 2, "memory pressure reached");
}
The invariant catches the overflow. The guidance drives the explorer to states where overflow is likely.
A convergence target.
#![allow(unused)]
fn main() {
assert_sometimes_less_than!(convergence_ms, 500, "cluster converges within 500ms");
}
The watermark ratchets downward. Each faster convergence is a branch point, and the report’s best: is the fastest convergence any seed achieved.
A throughput floor across seeds.
#![allow(unused)]
fn main() {
assert_sometimes_greater_than_or_equal_to!(committed, 1_000, "a seed commits 1,000 transactions");
}
Under chaos most seeds fall short. One seed that gets there clears the coverage violation and tells you the throughput ceiling is reachable at all.
A bound and a direction are two different statements. Write the bound as an invariant. Write the direction as guidance. The two lines together give you correctness and search from the same number.
Compound Assertions
- assert_sometimes_all!: Several Things at Once
- assert_sometimes_each!: Every Distinct Value
- Quality Watermarks
- Budgets and Limits
- Choosing Between the Two
A boolean assertion checks one condition. A numeric assertion tracks one value. The states that matter most involve several things at once: a leader is elected and every replica is caught up and no partition is open. Or we want every distinct value covered, not just the first one chaos stumbles on. Two macros handle these cases.
assert_sometimes_all!: Several Things at Once
assert_sometimes_all! takes a message and a list of named booleans.
#![allow(unused)]
fn main() {
assert_sometimes_all!("cluster fully operational", [
("leader_elected", has_leader),
("replicas_synced", all_replicas_in_sync),
("no_partitions", partition_count == 0),
("clients_connected", active_clients > 0),
]);
}
The slot tracks a frontier: the highest number of propositions that were true at the same time. It starts at 0. The first evaluation with one true proposition moves it to 1, the first with two moves it to 2, and so on. The target is the number of propositions, 4 here. The slot kind is BooleanSometimesAll, printed as frontier in the report:
○ frontier "cluster fully operational" 1,000 calls frontier: 3/4 combinations: 5
Three contract rules:
- The status stays
○(miss) until the frontier reaches the target. - The coverage violation reads
assert_sometimes_all!('...') reached frontier 3/4. UntilCoverageStabletreats the slot as reached only when the frontier is full.
A frontier advance is a FrontierAdvance discovery. That gives the explorer stepping stones. Consider a two-phase commit:
#![allow(unused)]
fn main() {
assert_sometimes_all!("distributed commit complete", [
("all_prepared", all_participants_prepared),
("all_voted_yes", all_votes_received),
("committed", transaction_committed),
("client_acked", client_received_ack),
]);
}
The explorer first finds a state where every participant is prepared, forks from there, and pushes for the votes. Then it finds a committed state and forks again. Without the assertion the full sequence must happen by chance in one timeline. With it, each stage is a base for the next.
Same count, different states. leader_elected alone and replicas_synced alone both score 1, but their futures differ. So the slot also keeps a 64-bit bitmap of the truth combinations it has seen. A combination that is new and has at least one true proposition is a BooleanCombination discovery, even when the count does not beat the frontier. The bitmap is a bloom filter: a hash collision can lose an optional hint but never touches the frontier or the counts. The report shows the bitmap’s population as combinations:.
Each evaluation records one pass, so the calls figure is the number of times the line ran. The frontier field is a byte: more than 255 propositions is not supported.
assert_sometimes_each!: Every Distinct Value
Sometimes one pass is not enough. A protocol with five states should visit all five. A cluster with four nodes should see every node take the lead. assert_sometimes_each! opens one bucket per distinct combination of identity keys.
#![allow(unused)]
fn main() {
// From the maze example: five nested probability gates per lock. Each
// (lock, depth, locks-already-open) tuple is its own bucket, so the report
// shows how deep each lock's chain was ever reached.
assert_sometimes_each!(
"gate",
[("lock", lock_id), ("depth", 1i64), ("locks", locks_open)]
);
}
Identity keys are (&str, integer) pairs, converted with the same saturating i64 rule as the numeric macros. A bucket’s identity is the FNV-1a hash of the message followed by each key name and value, so ("ab", v) and ("a", ..), ("b", ..) cannot collide by concatenation. The first hit of a bucket is a BucketFirst discovery. Every later hit adds to the bucket’s pass count.
The report groups buckets by site:
━━━ Buckets (23 across 2 sites) ━━━━━━━━━━━━━━━━━━━━━━━━━━
"gate" 21 buckets 48,120 hits
"lock opens" 2 buckets 19 hits
SimulationReport::bucket_summaries carries the same numbers as BucketSiteSummary { msg, buckets_discovered, total_hits }.
The explorer uses buckets to spread its budget. Antithesis showed this with The Legend of Zelda: a SOMETIMES_EACH keyed on screen coordinates made the explorer visit all 128 overworld screens instead of circling the start area. For a state machine the same idea is one line:
#![allow(unused)]
fn main() {
assert_sometimes_each!("raft state exercised", [
("node", node_id),
("state", raft_state as i64),
]);
}
Moonpool’s runner uses it on itself. Every hint! decision records assert_sometimes_each!("hint_struck", [("killed", i64::from(strike == Strike::Killed))]), so the report shows both outcomes, struck and spared, as two buckets.
Quality Watermarks
Reaching a bucket is not the same as reaching it in good shape. Antithesis’s Castlevania analysis calls the bad case a doomed state: the explorer gets to the boss room with one hit point and nothing useful can follow. An optional second list of quality keys fixes that.
#![allow(unused)]
fn main() {
// From the dungeon example: every floor, and every floor with health left.
assert_sometimes_each!(
"stairs with key",
[("floor", i64::from(level))],
[("health", i64::from(hp_bucket))]
);
}
Each bucket remembers its best quality score. A hit with a better score is a BucketQuality discovery, and the explorer re-forks from the healthier state. Up to four quality keys are packed into one 64-bit score. The first key is the most significant. Each key keeps only its low 16 bits, so scale values into 0..=65535 before passing them, and keep “higher is better” as the convention.
Quality values are not part of the bucket’s identity. Health 3 and health 9 on floor 2 are the same bucket with two scores.
Budgets and Limits
The bucket table is fixed. The constants are a contract that changes the saturation history of every campaign, so treat them as a wire format.
| Limit | Value | Where |
|---|---|---|
| Buckets per run | 256 | MAX_EACH_BUCKETS |
| Identity keys per site | 6 | MAX_EACH_KEYS, extra keys are ignored |
| Quality keys per site | 4 | extra keys are ignored |
| Message bytes stored per bucket | 32 | longer messages are truncated for identity |
Propositions per sometimes_all | 255 | the frontier is a u8 |
Overflow is loud. An observation that finds the table full is counted, and the run fails with each-bucket table dropped N sometimes_each observations. A bucket reached by a different message or key set with the same hash is flagged, and the run fails with bucket [..] collides with another key set.
256 buckets go fast. Two identity keys with 100 values each would need 10,000. Keep keys coarse: bucket a 256x256 grid into 16x16 regions, bucket hit points into four bands as the dungeon example does with hp_bucket. A good test: if you would not write a separate test case for each value, it should not be its own bucket.
Choosing Between the Two
- Several conditions that must hold at the same time:
assert_sometimes_all!. - One condition that must hold for every value:
assert_sometimes_each!.
assert_sometimes_all! has a contract: the run reports a gap until every proposition was true together. assert_sometimes_each! has none, because the table cannot list a bucket it never saw. Its coverage statement is the bucket list in the report. Both are guidance assertions. Both fork. Both turn one line into coverage that would otherwise take dozens of hand-written scenarios.
Events and Invariants
- Emitting Events
- The Invariant Trait
- Querying the Timeline
- Reading from a Workload
- The Fault Timeline
- Simulation Time in Log Output
- Snapshots, Timelines, Assertions
- Performance
Assertions live inside one workload or one process. They check local facts: this response matches what we sent, this gauge is not negative. Some properties span the whole system. “At most one leader per term” is owned by no single node. “Total money is constant across transfers” needs every account. And some properties span time: a term number never decreased, the lock was taken before the write. A snapshot of the latest state cannot see a balance that went 100, 50, 100.
Both need the same two parts: an append-only timeline of events, and a watcher that runs as the simulation advances.
How would you find a dual leader in production? You would query your traces: every leader_elected event, grouped by term, flagged when two nodes claim the same one. Moonpool runs that query, on those traces, inside the simulation. The instrumentation you ship for observability is the test oracle.
Emitting Events
A correctness fact is a plain tracing event. No marker, no derive, no moonpool API:
#![allow(unused)]
fn main() {
// Inside a process or workload: what you would write for production anyway.
tracing::info!(target: "raft", term, leader = %my_ip, "leader_elected");
}
Three rules:
- The message is the event name. Use a constant like
"leader_elected", not a sentence with values in it. Events are grouped and queried by name. - Use
%for strings.%valuerecords theDisplayform.?valueon aStringkeeps theDebugquotes. INFOor above.debug!andtrace!events are not captured by default.
Do not add a time_ms field. The layer stamps simulation time itself.
Where does the source come from? The orchestrator wraps every process and workload task in a span with its ip (info_span!("process", ip = %ip)). The capture layer walks the span scope and attributes the event to the nearest enclosing actor. A task spawned with ctx.task().spawn_task() runs inside the span that was current at spawn time, as tokio::spawn does, so a background handler’s events carry its owner’s ip. Events outside any actor span, from the orchestrator or the runtime, are dropped.
Each captured event is a TraceEvent:
#![allow(unused)]
fn main() {
pub struct TraceEvent {
pub seq: u64, // global order across all names, reset per seed
pub time_ms: u64, // simulation time at capture
pub source: String, // ip of the enclosing actor, or "sim" for faults
pub target: String, // tracing target, e.g. "raft"
pub level: tracing::Level,
pub name: String, // the message, e.g. "leader_elected"
pub fields: BTreeMap<String, FieldValue>,
}
}
seq orders events across names, even at the same time_ms. Typed accessors read fields: e.u64("term"), e.i64("delta"), e.f64("ratio"), e.bool("ok"), e.str("leader"). Each returns an Option.
One emission, two audiences. In simulation SimulationLayer captures the event and invariants check it. In production no layer is installed, and the same event goes to fmt, OpenTelemetry or JSON. Nothing in the call is simulation-specific.
Below the floor is free. SimulationBuilder::run installs the layer on a LevelFilter at the run’s trace floor, INFO by default. A DEBUG or TRACE span or event is disabled at the subscriber and costs one level compare. Instrument hot paths with #[tracing::instrument(level = "trace")] without fear. To debug one seed, lower the floor with SimulationBuilder::trace_level(LevelFilter::DEBUG). The floor never touches scheduling or randomness, so the seed replays identically at any level. The run is only slower and the capture larger.
The Invariant Trait
An invariant runs after every simulation step:
#![allow(unused)]
fn main() {
pub trait Invariant: 'static + Send {
fn name(&self) -> &str;
fn observe(&self, q: &dyn TraceQuery, sim_time_ms: u64);
fn reset(&mut self) {}
}
}
observe gets a TraceQuery view of the timeline and the current simulation time. Report a violation with assert_always!, never with panic!: the macro records the seed and lets the run continue. Steps batch events, so one observe call may see several new ones. The runner calls reset before each seed to clear cursors and tracking sets.
Register invariants on the builder:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.workload(LeaderWorkload::new())
.invariant(SplitBrainInvariant::new())
.invariant_fn("commit_volume", |q, _t| {
assert_always!(q.len("commit") <= 10_000, "runaway commit volume");
})
.run()?;
}
invariant_fn wraps a closure for quick one-off checks.
Querying the Timeline
TraceQuery has three methods. since(name, &cursor) returns the events newer than the cursor and advances it. It is cheap, and the right default. snapshot(name) returns everything since the start of the seed. len(name) counts.
The canonical example is crates/moonpool-sim/tests/leader_election.rs. A workload emits leader_elected events, and an invariant checks that no two nodes claim the same term:
#![allow(unused)]
fn main() {
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use moonpool_sim::{Invariant, TraceQuery, assert_always};
struct SplitBrainInvariant {
cursor: Cell<usize>,
// term -> first leader seen. Lives across observe() calls within a seed.
seen: RefCell<BTreeMap<u64, String>>,
}
impl Invariant for SplitBrainInvariant {
fn name(&self) -> &'static str { "no_split_brain" }
fn observe(&self, q: &dyn TraceQuery, _sim_time_ms: u64) {
let mut seen = self.seen.borrow_mut();
for e in q.since("leader_elected", &self.cursor) {
let term = e.u64("term").expect("leader_elected carries a term");
let leader = e.str("leader").expect("leader_elected carries a leader").to_owned();
if let Some(prior) = seen.get(&term) {
// format! is fine here: this branch is the bug, not the hot path.
assert_always!(
prior == &leader,
format!("split-brain at term {term}: {prior} vs {leader}")
);
} else {
seen.insert(term, leader);
}
}
}
fn reset(&mut self) {
self.cursor.set(0);
self.seen.borrow_mut().clear();
}
}
}
The test’s bug path emits a second leader for term 1. The invariant fires on the step that captured it, and the report shows failed_runs >= 1.
Reading from a Workload
ctx.observability() returns the layer handle, and the handle implements TraceQuery too. A Workload::check can run the same queries at the end of the seed:
#![allow(unused)]
fn main() {
let q = ctx.observability();
let total: u64 = q.snapshot("transfer").iter().filter_map(|e| e.u64("amount")).sum();
assert_always!(total <= max_funds, "spent more than reserves");
}
snapshot returns an empty Vec when nothing was emitted under that name.
The Fault Timeline
Every fault the simulator injects lands in the same timeline under the name SIM_FAULT_EVENT_NAME ("sim_fault"), with source = "sim" and a kind field. The engine records them internally (SimWorld::take_faults()), and the runner merges them after each step. No workload code is involved.
#![allow(unused)]
fn main() {
use std::cell::Cell;
use moonpool_sim::{Invariant, SIM_FAULT_EVENT_NAME, TraceQuery, assert_always};
struct KillRateInvariant { cursor: Cell<usize>, kills: Cell<usize> }
impl Invariant for KillRateInvariant {
fn name(&self) -> &str { "kill_rate" }
fn observe(&self, q: &dyn TraceQuery, _t: u64) {
for e in q.since(SIM_FAULT_EVENT_NAME, &self.cursor) {
if e.str("kind") == Some("process_force_kill") {
self.kills.set(self.kills.get() + 1);
}
}
assert_always!(self.kills.get() <= 10, "too many kills");
}
fn reset(&mut self) { self.cursor.set(0); self.kills.set(0); }
}
}
The kind values, with their fields:
| Family | kind | Fields |
|---|---|---|
| Process | process_graceful_shutdown | ip, grace_period_ms |
process_force_kill | ip, cause (grace_period_expired, crash, crash_and_wipe, restart_in_place) | |
process_restart | ip | |
| Network | partition_created, partition_healed | from, to |
send_partition_created, send_partition_healed, recv_partition_created, recv_partition_healed | ip | |
interface_clogged | ip, direction (send, recv, both), duration_ms | |
random_close | connection_id | |
black_hole | connection_id, direction (send, recv, both) | |
bit_flip | connection_id, flip_count | |
| Storage | storage_read_fault, storage_sync_fault | ip, file_id |
storage_write_fault | ip, file_id, write_kind (phantom, misdirected, corruption) | |
storage_disk_failure, storage_crash, storage_wipe | ip |
The value is correlation. A conservation violation at t=5000 next to a process_force_kill at t=4980 is a different bug from one with no fault nearby. Both sit in one timeline with one clock, the way an alert window sits next to infrastructure events in a log aggregator.
Simulation Time in Log Output
tracing_subscriber::fmt::layer() prints wall-clock time. In a simulation every line gets the same timestamp. SimTime is a FormatTime that prints the simulated clock instead, read through the Clock trait that SimulationLayerHandle implements:
#![allow(unused)]
fn main() {
use moonpool_sim::{SimTime, SimulationLayer};
use tracing_subscriber::layer::SubscriberExt;
let sim_layer = SimulationLayer::new();
let handle = sim_layer.handle();
// SimulationLayer goes first so its clock is current when fmt formats.
let subscriber = tracing_subscriber::registry()
.with(sim_layer)
.with(tracing_subscriber::fmt::layer().with_timer(SimTime::new(handle.clone())));
let _guard = tracing::subscriber::set_default(subscriber);
}
sim+ 1.234s INFO raft: term=3 leader=10.0.1.2 leader_elected
The orchestrator pushes handle.set_sim_time_ms(..) after each step. The handle is per layer, so two simulations in one process keep separate clocks. In production, use the plain wall-clock formatter.
Snapshots, Timelines, Assertions
Three tools, three jobs.
StateHandle::publish / get holds the latest snapshot. Workloads publish a model with ctx.state().publish("model", model.clone()) and read it in check. Good for “the sum of balances equals deposits minus withdrawals”.
The trace timeline holds history. Good for “no message was received before it was sent”, “the term never decreased”, “retries stayed high after the partition healed”, which is the metastable-failure signature.
Assertions inside a workload or process check the local view.
A rule of thumb: a property that involves more than one process or workload is an invariant. A property about one actor’s view is an assertion. Inside an invariant, still use the assertion macros, so a failure carries the seed.
Performance
Invariants run after every step, thousands of times per seed. Use since with a cursor, not snapshot. Compare counters, check a predicate. The format! in a failure message is fine because it runs only on failure. A slow check, such as replaying a log, belongs in Workload::check at the end of the seed.
Application Metrics
- Reading Metrics from Process Code
- Why Per Node, and Why Per Iteration
- Time Series, Not Samples
- Timing on the Simulated Clock
- Reading Metrics in check()
- Asking Questions: Metric Queries
- Worked Example: a Metastable Failure
- What Metrics Are Not
- Beyond Prometheus
Your service is already instrumented. Somewhere there is a prometheus::Registry, a requests_total counter, a latency histogram, a gauge for queue depth. That instrumentation exists because it is how you understand the system in production.
Moonpool reports on it. Register one metrics source per simulated node, and the counters, gauges and histograms your code already keeps appear in the simulation report: aggregated across seeds, attributed per node, with an exact time series of every change.
use std::sync::Arc;
use moonpool_prometheus::PrometheusSource;
let report = SimulationBuilder::new()
.metrics_factory(|_ip| Arc::new(PrometheusSource::default()))
.processes(3, || Box::new(MeteredKvNode::new()))
.workload(MeteredKvWorkload::default())
.set_iterations(10)
.run()?;
report.eprint();
━━━ Metrics (15 series) ━━━━━━━━━━━━━━━━━━━━━━━━━━
kv_keys_stored{instance="10.0.1.1"} 15.81 avg 1 min 16 max
kv_request_latency_seconds{instance="10.0.1.1"} 130.83 total 6,667 obs 0.02 avg
kv_requests_in_flight{instance="10.0.1.1"} 2.22 avg 0 min 3 max
kv_requests_rejected_total{instance="10.0.1.1"} 8,620 total 862 per seed
kv_requests_served_total{instance="10.0.1.1"} 6,667 total 666.70 per seed
The runnable version is crates/moonpool-sim-examples/src/metrics_service.rs: a key/value node with admission control, metered the way a production service is.
Reading Metrics from Process Code
The source is reachable from any process or workload through its context:
let source = ctx.metrics::<PrometheusSource>()
.ok_or_else(|| invalid_state("no metrics factory registered"))?;
let served = source.counter("kv_requests_served_total", "Requests served")?;
let in_flight = source.gauge("kv_requests_in_flight", "Requests currently being served")?;
let latency = source.histogram_with_buckets(
"kv_request_latency_seconds",
"Time to serve one request",
vec![0.001, 0.005, 0.010, 0.025, 0.050, 0.100, 0.250],
)?;
served.inc();
counter, gauge, histogram, histogram_with_buckets and histogram_with_opts are get-or-register. The vector forms counter_vec, gauge_vec and histogram_vec take label names and hand out children with with_label_values. Get-or-register matters because a source outlives process reboots. A rebooted process looks its metrics up instead of failing with AlreadyReg.
To wrap a registry your application already built, use PrometheusSource::new(registry).
Why Per Node, and Why Per Iteration
Every simulated node shares one OS process. One shared registry would merge three nodes’ counters into one number. So the factory is keyed by IP, and each sample carries an instance label with the node’s IP, Prometheus’s own label for a scrape target.
The factory also runs again every iteration. Most registries have no reset, so a reused instance would make seed 50 report the sum of fifty runs. A custom fault injector uses fault_factory for the same reason.
A reboot is the exception. The source is keyed by IP, so a restarted process keeps its counters, as a real node’s /metrics does across a restart on the same host.
Time Series, Not Samples
Metrics created through the source return instrumented handles: SimCounter, SimGauge, SimHistogram. Every inc(), set() and observe() records a point stamped with the simulated clock. There is no scrape interval. The series is exact, and it replays bit for bit for a given seed.
let metrics = report.individual_metrics[0].as_ref().expect("seed passed");
for (series, points) in &metrics.app_series {
for point in points {
println!("{series} {} at {}ms", point.value, point.time_ms);
}
}
That is what lines a latency spike up with the partition that caused it. The aggregate on report.app_metrics (MetricAggregate) uses the series for min, max and mean, so a gauge reports the range it moved over, not the value it rested at when the run ended.
Series are capped at 10,000 points each (DEFAULT_SERIES_CAPACITY, tunable with PrometheusSource::set_series_capacity), so a hot loop cannot grow a test’s memory without bound. SimulationMetrics::dropped_metric_points is non-zero when a series was truncated.
Timing on the Simulated Clock
Use the handle’s start_timer, not prometheus’s own:
let timer = latency.start_timer(ctx.time());
let response = client.request(req).await?;
timer.stop_and_record();
prometheus::Histogram::start_timer() reads the wall clock. Inside a simulation that records how fast your laptop was, and it differs on every replay of the same seed. SimHistogram::start_timer takes any TimeProvider and reads the simulated clock. stop_and_discard() drops a timing you do not want, and observe_duration(d) records a Duration you measured yourself.
Pick bucket bounds that bracket what the simulation produces. Prometheus’s defaults are tuned for real HTTP latency. A distribution that lands in one bucket tells you nothing.
Reading Metrics in check()
A workload can read every node’s source through ctx.metrics_handle(). That is how a test asserts on what the system recorded about itself:
async fn check(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let handle = ctx.metrics_handle();
let samples = handle.collect_all();
assert_always!(!samples.is_empty(), "the nodes recorded something over five simulated seconds");
let series = handle.collect_series();
assert_sometimes!(
series.keys().any(|k| k.starts_with("kv_request_latency")),
"latency observations are recorded as a time series"
);
Ok(())
}
Asking Questions: Metric Queries
A raw series is data, not an answer. After five hundred seeds the question is “what throughput did this hold, and which seed was worst?” So the runner declares what it wants to know, and the report answers:
use std::time::Duration;
use moonpool_sim::{Fill, Mean, MetricQuery, Percentile};
let report = SimulationBuilder::new()
.metrics_factory(|_ip| Arc::new(PrometheusSource::default()))
.metric(
MetricQuery::select("requests_total")
.label("operation", "write")
.rate()
.bucketize(Duration::from_secs(60), Mean)
// A minute with no requests is zero per second, not missing data.
.fill(Fill::Value(0.0))
.reduce(Mean)
.named("write_throughput"),
)
.metric(
MetricQuery::select("request_latency_seconds")
.bucketize(Duration::from_secs(60), Percentile(0.99))
.named("write_p99"),
)
.processes(3, || Box::new(MyNode::new()))
.workload(MyWorkload::default())
.set_iterations(500)
.run()?;
━━━ Metric Queries (2) ━━━━━━━━━━━━━━━━━━━━━━━━━━
write_throughput
requests_total{operation="write"} → rate → 60s buckets (mean) → reduce (mean) · scalar per run · runs: 500
min 5,421 seed=9182
mean 5,973
max 6,102 seed=224
across runs: p50 5,991 p95 6,071 p99 6,094
min and max name the seed, so the worst run goes straight into set_debug_seeds(vec![9182]). Results travel with the report in SimulationReport::metric_queries, one MetricQueryReport per query, with every per-seed MetricQueryRow.
The Operations
| Op | What it does |
|---|---|
select(name) + .label(k, v) | pick series by name and label subset |
.rate() | monotonic counter to per-second rate, on simulated time |
.bucketize(width, agg) | regularize into fixed simulated-time buckets |
.fill(policy) | repeat an existing value into empty buckets |
.interpolate() | compute empty buckets from their neighbours |
.map(window, agg) | rolling window over one series |
.reduce(agg) / .reduce_by(label, agg) | combine across series |
.named(name) | finish the plan |
The aggregators are Min, Mean, Max and Percentile(p). Nothing else.
Counter semantics are explicit. .rate() is the only place a series is read as a counter. bucketize(_, Mean) on a raw counter reports the mean cumulative value, not a throughput. A drop in a counter is read as a reset, Prometheus style. Every counter starts at zero at simulated time zero, so the first increment is rated too.
Empty Buckets
bucketize omits a bucket nothing landed in, because a gap and a zero are different facts. Two ops say which one you mean, the way Warp 10 splits them. .fill repeats a value the series has. .interpolate computes one it never had.
| Op | Gives empty buckets | Leaves empty |
|---|---|---|
.fill(Fill::Previous) | the last known value | leading gaps |
.fill(Fill::Next) | the next known value | trailing gaps |
.fill(Fill::Value(v)) | v, no neighbour needed | nothing |
.interpolate() | a point on the line between neighbours | leading and trailing gaps |
bucket 0 | 1 | 2 | 3 | 4
no fill 10 | - | - | 40 | -
.fill(Fill::Previous) 10 | 10 | 10 | 40 | 40
.fill(Fill::Next) 10 | 40 | 40 | 40 | -
.fill(Fill::Value(0.0)) 10 | 0 | 0 | 40 | 0
.interpolate() 10 | 20 | 30 | 40 | -
Use a fill when the quantity holds between observations, or is zero when nothing happened. Use .interpolate() when it moves continuously: a queue depth, a replication lag. They compose in call order. The grid runs from bucket zero to the run’s end, so with Fill::Value(0.0) every window sees every seed and min and max compare like with like.
Series identity is name plus label set. Matching is by subset: a query naming operation="write" also matches a series carrying instance="10.0.1.1". Every result row carries seed and run_id as moonpool fields, never injected into your labels.
Percentiles That Mean Something
mean(p99(node_a), p99(node_b)) is not a p99, and neither is p99(p99(a), p99(b)). Once observations collapse into a percentile, a percentile of that has no meaning.
The builder enforces this at compile time. Each stage tracks what its values are, and Percentile applies only where the observations survive:
// Compiles: the raw histogram observations are still there.
MetricQuery::select("request_latency_seconds")
.bucketize(Duration::from_secs(60), Percentile(0.99))
.named("p99");
// Does NOT compile: p99 of per-bucket p99s.
MetricQuery::select("request_latency_seconds")
.bucketize(Duration::from_secs(60), Percentile(0.99))
.reduce(Percentile(0.99))
.named("nonsense");
Min, Mean and Max apply anywhere. “The mean of each node’s p99” is a useful number as long as nobody calls it a p99, which is why the report prints each query’s provenance: observations, scalar or percentile-derived. The p50, p95 and p99 in the summary block are a different dimension, taken across runs with one value per seed.
Worked Example: a Metastable Failure
Totals say what a run did. The series says when it went wrong, which is the only way to see a failure that outlives its cause. crates/moonpool/examples/metastable_grpc_retry_storm.rs is the demonstration: a real gRPC service with a bounded worker pool, an open-loop client with an aggressive timeout and retries, and one temporary slowdown in the middle of the run.
cargo run --release --example metastable_grpc_retry_storm \
--features hyper,prometheus -- --seed 1
It runs query pipelines over the recorded series and prints the buckets as an ASCII graph, one column per simulated second. The slowdown lasts 650 ms. The service never recovers: retries from that window keep the server saturated for the remaining 29 seconds at the same offered load it carried before. The trigger is gone, the bad state is not.
--search 0..64 runs a range of seeds and prints one line each. Some seeds absorb the trigger and others fall into the storm. Same code, same configuration, different seed. That is the evidence that the bad state is a second stable operating point.
What Metrics Are Not
Metric values are reported, never used to steer the simulation. Not everything in a registry is deterministic. A metric fed from the wall clock is not. The runner treats a scrape as an observation. To enforce a property, assert on it: metrics tell you the shape of a run, assertions tell you whether it was correct.
Under fork-based exploration, only root timelines are scraped. Explored timelines report through the shared assertion table, which carries no metric values.
Beyond Prometheus
PrometheusSource is one implementation of MetricsSource, a registry-agnostic trait in moonpool-core:
pub trait MetricsSource: Send + Sync + 'static {
fn collect(&self) -> Vec<MetricSample>;
fn set_clock(&self, clock: Arc<dyn MetricClock>) {}
fn series(&self) -> BTreeMap<String, Vec<MetricPoint>> { BTreeMap::new() }
fn dropped_points(&self) -> u64 { 0 }
}
collect alone makes a scrape-only adapter over any registry. The other three deliver an exact time series, with SeriesRecorder from moonpool-core doing the stamping and capping. An OpenTelemetry adapter slots in the same way, with no change to the runner.
Designing Workloads That Find Bugs
- Strategy vs Tactics
- The Operation Alphabet
- Swarm the Alphabet
- One Workload, Many Behaviours
- Invariant Patterns
- Assertions as Memos
- Generate Sufficient Workload
A workload that exercises only the happy path finds nothing. The earlier chapters covered how to write a workload, wire assertions and register invariants. This chapter is about what to put inside: which operations to generate, what to measure, and why one wide workload beats ten narrow ones.
Strategy vs Tactics
Will Wilson’s framework for simulation workloads has two independent dimensions.
Strategy is what you measure and optimise: code coverage, grid cells visited, time the system stays alive. Strategy decides whether a run was productive.
Tactics is how you generate inputs: uniform random, weighted toward writes, biased toward boundary values. Tactics is the raw material.
They are independent. Brilliant tactics with crude strategy works. Coverage-guided strategy with pure random tactics works. You need at least one to be good. Moonpool’s strategy is built in: UntilCoverageStable watches real code coverage and the assertion table, and exploration forks at new assertion outcomes. Tactics is yours.
But “pure random” has a trap.
The white noise paradox. Random inputs are maximally random at the micro level and minimally random at the macro level. Drop each packet with 5% probability and a sustained 10-second partition has probability 0.05 to the power of every packet crossing the link in those seconds. Zero, in practice. Random drops average out to a slightly degraded, fundamentally healthy network. The same holds for random user inputs: random button presses average out to no sustained action. The system never reaches the deep states where bugs hide.
This is why Moonpool’s faults are correlated: a partition that exists at t=0 is almost certainly still there at t=1, and a new one starting is rare. The same reasoning shapes the workload.
The Operation Alphabet
The operations a workload generates fall into three classes.
Normal operations are production-like traffic. Reads, writes, queries. Match the real ratio: 80% reads and 20% writes if that is what users do.
Adversarial inputs are what users actually send. Empty strings. Boundary values (0, -1, u64::MAX). Maximum-length fields. Keys that collide. A workload without them tests a polite fiction.
Nemesis operations deliberately break things. Kill a process mid-transaction. Trigger compaction under peak writes. Send conflicting writes to one key from several clients at once.
Normal operations test the happy path. Adversarial inputs test the validation path. Nemesis operations test the recovery path, where most bugs live.
#![allow(unused)]
fn main() {
match ctx.random().random_range(0..100) {
0..60 => self.do_normal_operation(ctx).await?,
60..80 => self.do_adversarial_operation(ctx).await?,
_ => self.do_nemesis_operation(ctx).await?,
}
}
Tune the weights from what the assert_sometimes! rows tell you.
Swarm the Alphabet
A complete alphabet has a hidden failure mode. Picking uniformly over the full set on every step keeps every operation slightly active, and they crowd each other out. A bounded queue with a 50/50 push/pop mix is a one-dimensional random walk. By the Hoeffding bound it takes tens of millions of steps to drift far enough to fill. The overflow bug is real, reachable, and never hit, because pop undoes what push makes. This is passive suppression, the effect swarm testing defeats for fault families, now inside the workload.
The fix is the same. Enable a random subset of the alphabet per seed and turn the rest fully off. Disable pop for some seeds and the queue fills in a handful of steps.
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.swarm_operations()
.workload_factory(|| Box::new(QueueWorkload::new()))
}
moonpool_sim::swarm_op_enabled(op_id) tells the workload whether operation op_id is in this seed’s subset. Without .swarm_operations() every operation reports enabled, so a default run uses the full alphabet and nothing changes. The dungeon example in crates/moonpool-sim-examples/src/dungeon.rs is the reference:
#![allow(unused)]
fn main() {
/// Each action is independently enabled about half the time this seed. A seed
/// that disables everything falls back to the full alphabet so the workload
/// always has something to do.
fn swarm_enabled_actions() -> Vec<u8> {
let enabled: Vec<u8> = (0..NUM_ACTIONS)
.filter(|&i| moonpool_sim::swarm_op_enabled(i))
.collect();
if enabled.is_empty() { (0..NUM_ACTIONS).collect() } else { enabled }
}
/// One draw per step, folded into the enabled subset.
fn pick_action_index(&mut self) -> u8 {
let raw = moonpool_sim::sim_random::<u64>();
let span = u64::try_from(self.enabled_actions.len()).expect("alphabet size fits in u64");
self.enabled_actions[usize::try_from(raw % span).expect("index fits")]
}
}
The remap matters. We draw once and fold into the subset. A loop that resamples until it lands on an enabled operation consumes a variable number of draws per step, and exploration keys its replay on the RNG call count. Keep the per-step draw count fixed and replay stays exact. When the full alphabet is enabled, enabled[raw % n] == raw % n, so a non-swarm run is byte-identical to one with no swarm code at all.
The mask is 256 bits, one per u8 operation id, drawn in the first four counted draws of every seed before any attrition or outage regime. swarm_op_enabled only reads a bit, so asking consumes nothing and the answer never depends on how often you ask. The two swarm axes compose: a seed picks a fault subset and an operation subset.
The payoff is an assertion only the swarm can reach. Emit it on the path where it holds, so it never records a check on seeds where the subset makes it impossible:
#![allow(unused)]
fn main() {
// Reachable only when every movement operation was masked off this seed.
if !enabled.iter().any(|&i| i < NUM_MOVE_OPS) {
moonpool_sim::assert_sometimes!(true, "movement suppressed by swarm");
}
}
crates/moonpool-sim/tests/swarm_op_alphabet.rs shows the assertion firing under .swarm_operations() and staying unreachable without it.
One Workload, Many Behaviours
It is tempting to write one workload per scenario: a read-heavy client, a write storm, a scanner, a crash-at-commit driver. Resist it. Every extra workload is a separate operation mix that must be scheduled, named and maintained, and the scenarios never combine. The write storm never meets the scanner on the same seed.
Prefer one workload with a wide alphabet and let the per-seed draws produce the scenarios:
swarm_operations()picks which operations exist this seed. The read-heavy client is the seed where writes are masked off. The write storm is the seed where reads are.WorkloadCount::Random(1..6)picks how many instances run, so contention varies.ChaosMode::Swarmpicks which fault families are on, so the same alphabet meets partitions on one seed and disk stalls on the next.- Inside the shipped code,
buggify_named!sites andset_activationgive a seed its own set of active scenarios, which Buggify covers.
One alphabet, one model, one set of assertions, thousands of behaviours. The campaign covers combinations no hand-written scenario list would contain, and a failing seed is still a single number to replay.
Invariant Patterns
Generating interesting operations is half the problem. Knowing whether the system behaved is the other half. Four patterns hold up.
Reference models keep an in-memory expected state and compare after every operation, as the workload chapter did. Use BTreeMap, never HashMap, so iteration order replays.
Conservation laws check quantities that must stay constant. Total record count across shards. Messages sent minus messages received. Committed values that must agree across replicas. Events and Invariants builds one.
Structural integrity walks a data structure after chaos. No cycles in a list. Balance at every B-tree level. Parent pointers that agree with children. These catch corruption a reference model cannot see.
Operation logging resolves commit uncertainty. A write that failed with a network error may or may not have committed. Log the intent beside the mutation, then reconcile the log against the state after recovery. Every simulation workload talks through an unreliable network, so every one needs this.
Assertions as Memos
Lawrie Green’s view: an assertion serves two readers at once.
It informs the computer. assert_sometimes! tells the explorer “this state is interesting, branch here”. assert_always! tells the runner “if this fails, stop and report”.
It documents the developer’s mental model. assert_always!(balance >= 0, "balance never negative") records a belief. When it fails on correct code, the belief was wrong, and that is itself a finding.
So assertions belong everywhere a developer has an opinion: the error path, the recovery path, the “this cannot happen” path that will happen in simulation.
Generate Sufficient Workload
One client sending sequential requests never triggers the races where distributed bugs hide. The system needs concurrency:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.processes(3, || Box::new(KvServer))
.workloads(WorkloadCount::Fixed(5), |_| Box::new(KvWorkload::new(200)))
.run()?
}
Five clients, 200 operations each, three servers, chaos on. The interleavings between concurrent operations across faulting servers are where bugs live.
There is a deeper principle. Hand-written test cases scale multiplicatively with system complexity: a 300-line feature can need 10,000 lines of tests for combinatorial coverage. A generator scales linearly. One new variant in the operation enum meets every other operation, every fault family and every timing on its own:
#![allow(unused)]
fn main() {
enum Operation {
Get { key: String },
Set { key: String, value: Vec<u8> },
Delete { key: String },
Scan { start: String, end: String },
// One line. The simulator pairs it with everything above, every fault
// family, and every interleaving, across every seed.
CompareAndSwap { key: String, expected: Vec<u8>, new: Vec<u8> },
}
}
That is the real payoff of simulation testing. Not that each test is better, but that the cost of coverage changes from exponential to linear.
Debugging a Failing Seed
A seed failed. The report printed Faulty seeds: [17429853261] and a red row in the assertion table. What now?
In production, debugging a concurrency bug means staring at interleaved logs from several nodes and trying to reconstruct a sequence you cannot replay. You form a theory, add logging, deploy, wait for the bug to come back, and hope the new lines captured enough. It is slow and often inconclusive.
Deterministic simulation changes this. A failing seed is not a clue. It is a recording. Same seed, same schedule, same draws, same bug, every time. Debugging becomes mechanical rather than archaeological.
The Workflow
- Reproduce alone. Pin the seed with
set_debug_seeds(vec![seed])andset_iterations(1). Raise the trace level. Confirm the same violation appears. - Read the timeline backwards. Start at the first
[ASSERTION FAILED]line. Later ones are usually cascade. Walk back through the events before it to the decision that caused it. - Understand the causal chain. Which delivery landed a stale message? Which timer fired? Which process was mid-reboot? Name the runtime decision, not just the symptom.
- Fix the root cause in the system under test. Never delete or weaken the assertion that caught it.
- Widen. Rerun the seed. Rerun the test file. Run
cargo xtask sim run-all, because every example is a regression surface for the scheduler. Cite the seed in the commit.
Each step is simple because determinism gives us repeatability. We do not hunt ghosts. We replay recordings.
What Makes This Different
Traditional debugging assumes non-determinism. You set a breakpoint and hope the thread schedule cooperates. You add a print and hope the race still happens. You run the test ten times and it passes nine.
With simulation, the seed controls everything: which connections fail, when timers fire, which ready task runs next, how long a delay takes, which fault families are on. Pin the seed and the execution is frozen. Add logging, set breakpoints, restructure the investigation, and the bug is still there, exactly where you left it.
One consequence is easy to miss. A pinned seed stops reproducing the moment the draw schedule moves, and in this repository the draw schedule moves with every runtime change: a new random decision, a reordered await in the runner, a new chaos surface on the builder. That is intended. The seed is a replay of one schedule, not a stable test case. When the fix changes the schedule, confirm it by running the campaign again, not by replaying the old seed.
The Three Chapters That Follow
Reproducing with FixedCount covers pinning a seed in a test, in an example binary, and from an exploration recipe, how to turn up the logging, and what to do when the bug refuses to come back.
Reading the Event Trace covers the event kinds the scheduler processes, where each one is produced, and how to read the timeline backwards from a violation.
Common Pitfalls is the list of mistakes we have made ourselves, so you can recognise them in one glance.
A Note on Non-Determinism
If the pinned seed runs clean, you have a different problem: something in the system is non-deterministic. That is valuable information. The usual sources are a direct tokio call that bypasses the providers, a HashMap whose iteration order leaks into behaviour, a wall-clock read, or a static that survives from one seed into the next. check_determinism() on the builder runs the seed twice and names the first draw where the two runs disagree. The next chapter shows how to bisect from there.
Reproducing with FixedCount
- Pinning a Seed
- Where the Builder Lives
- Turning Up the Logging
- When the Bug Disappears
- From Reproduction to Investigation
The report said seed 17429853261 failed. The first step is always the same: run that seed alone.
Pinning a Seed
Add two calls to the builder that failed:
#![allow(unused)]
fn main() {
SimulationBuilder::new()
.workload_factory(|| Box::new(KvWorkload::new(200)))
// ... the same processes, chaos and invariants as the campaign ...
.set_iterations(1)
.set_debug_seeds(vec![17429853261])
.run()?
}
set_debug_seeds supplies the seed. set_iterations(1) switches to IterationControl::FixedCount(1) so the runner stops after it. Both are needed: with the default UntilCoverageStable control the runner uses the pinned seed first and then keeps drawing fresh seeds until the plateau. Everything else on the builder must stay as it was. The chaos surfaces, the process counts and the workload factories all draw from the stream, so a different builder is a different schedule and the seed means nothing.
Run it. The same [ASSERTION FAILED] line, the same panic, the same deadlock, every time.
Where the Builder Lives
A nextest test. Most integration tests build their own SimulationBuilder. Add the two calls locally and run the one test:
nix develop --command cargo nextest run -p moonpool-sim leader_election --nocapture
Do not commit the pin. The schedule moves with the next runtime change and the pinned seed stops meaning anything.
An example binary. The binaries in crates/moonpool-sim-examples/src/bin/sim/ set seeds in code, not on the command line. Edit the builder call the same way and rerun through xtask, or through cargo run --bin sim-<name> for a faster, coverage-blind run.
An exploration find. The report’s exploration section carries a BugRecipe { seed, recipe }. Replay the exact timeline, root seed plus RNG breakpoints, with one call:
#![allow(unused)]
fn main() {
.replay_timeline(seed, recipe)
}
It sets the seed, FixedCount(1), and installs the recipe’s (rng_call_count, new_seed) breakpoints before the run. Exemplars and Continuations explains the recipe format.
Turning Up the Logging
A campaign runs quiet. A pinned seed can afford the firehose. Two knobs, and no RUST_LOG plumbing:
#![allow(unused)]
fn main() {
moonpool_sim::init_sim_tracing(tracing::Level::DEBUG);
SimulationBuilder::new()
.trace_level(tracing::level_filters::LevelFilter::TRACE)
// ...
}
init_sim_tracing sets the stdout subscriber’s level. trace_level sets the floor the simulation layer enables and the timeline captures. At the default INFO every DEBUG and TRACE span in process and workload code is disabled before the registry allocates it. Lower the floor and those spans come alive and their events join the timeline, at the price of a slower run and a much larger capture. The floor never changes scheduling or randomness, so the seed replays identically at any level.
Print simulated time on every line, not wall-clock time: SimTime from Events and Invariants does that, and it is the difference between a log you can read and one you cannot.
When the Bug Disappears
You pinned the seed, the run is green. Something draws outside the single simulation stream, or something the seed does not control decides behaviour. Add the canary:
#![allow(unused)]
fn main() {
.check_determinism()
}
The runner now runs the seed twice. The first pass records a fingerprint after every draw on the simulation stream. The second pass replays and compares. The seed fails with the always-assertion "determinism canary: replay matched the recorded draw sequence" and the detail names the verdict:
Diverged { index, expected, actual }: draw numberindexfingerprinted differently. The stream or the logical clock had already diverged by then.ExtraDraws { recorded }: the replay drew more often than the record.Unconsumed { recorded, consumed }: the replay drew a matching prefix and then stopped early.
The canary needs factory workloads: an instance workload carries state into the second run and diverges by construction. crates/moonpool-sim/tests/determinism_canary.rs shows it tripping on a process-wide counter that survives from the first run into the second.
A canary says that the runs diverged and at which draw, not why. Bisect toward the index:
- Read
moonpool_sim::rng_call_count()at suspect points and log it. The count at the moment of the divergence is the fingerprint of “where we are”. - Install
moonpool_sim::set_rng_breakpoints(vec![(target_count, new_seed)])before the run to reseed the stream once the count passestarget_count. The run before the breakpoint is unchanged, so moving the target narrows the window. - Compare the two runs’ logs at the same count. The first line that differs is the culprit.
The usual culprits:
- A
rand::thread_rng()or aHashMapiteration whose order reaches a decision. - A wall-clock read (
Instant::now(),SystemTime::now()) instead ofctx.time(). - A
tokio::time::sleep,tokio::spawnortokio::select!instead of the provider andmoonpool_sim::select!. - A thread-local or
staticthat survives from one seed into the next. - A detached task from a previous seed still running.
- Real I/O: files, sockets, environment variables.
Fix the leak, drop the canary or keep it in a test, and the original seed reproduces again.
From Reproduction to Investigation
With a reliable reproduction, the question changes from “does it happen” to “why”. The event trace shows the causal chain: which events fired, in what order, with what result. With a pinned seed you can also set an IDE breakpoint and step the exact path, because the executor polls everything on one thread.
You are not searching for a bug any more. You are reading a recording of one.
Reading the Event Trace
- One Scheduler, One Clock
- The Event Kinds
- Faults in the Timeline
- Reading Backwards
- The RNG Call Count
- Infrastructure vs Application Events
- Practical Tips
You have a pinned seed that reproduces the failure. Now you need to know what happened. The event trace is the primary tool, and reading it is a skill: know the event kinds, know who produces them, and read backwards.
One Scheduler, One Clock
The simulation runs on a single Scheduler<Event>, a priority queue ordered by logical time and then by a stable sequence number. It alone owns the clock. Two events at the same time are processed in FIFO order of their sequence numbers. Scheduling into the past clamps to now. A cancelled entry disappears without advancing time.
The scheduler coordinates delayed work without owning resource state. It hands NetworkEvents to the NetworkSimulation and StorageEvents to the StorageEngine. Each component updates its own state, resolves the exact pending operation, and returns ordered effects, fault records and wake batches. The world applies the effects and wakes tasks only after it releases its lock. That ownership split is why every event names exactly one operation, and why a trace line is enough to find the pending future it completed.
The Event Kinds
| Event | Meaning | Produced by |
|---|---|---|
Timer { task_id } | A sleep or timeout elapsed. The heartbeat of every workload. | sim/sleep.rs through the scheduler |
Network(OperationReady) | A delayed bind, connect or accept completed its latency. | network/sim/engine.rs |
Network(ProcessSendBuffer) | The next chunk of a connection’s send queue goes onto the wire. | network/sim/delivery.rs |
Network(Delivery { connection_id, seq }) | An in-flight item (data or FIN) reached its delivery time. A wake-up, not a payload: the item still sits in the sender’s flight, so a partition injected after scheduling still decides its fate. | network/sim/delivery.rs |
Network(PartitionRestore / SendPartitionClear / RecvPartitionClear) | An expired cut is removed and held items are re-timed. | network/sim/faults.rs |
Network(ClogClear / ReadClogClear) | A clog expires, only if the event still matches the active deadline. | network/sim/faults.rs |
Network(Maintenance) | A wake-up to evaluate network maintenance at this time. | network/sim/engine.rs |
Storage(StorageEvent) | One exact read, write, sync or set-length completes, by operation id and handle id. | storage/sim/complete.rs, scheduled in storage/sim/engine.rs |
ProcessGracefulShutdown { ip, .. } | A graceful reboot begins: the token fires, the grace period starts. | runner/process_manager.rs |
ProcessForceKill { ip, cause, .. } | The process task is aborted. cause is GracePeriodExpired, Crash, CrashAndWipe or RestartInPlace. | runner/process_manager.rs, SimContext::crash_self |
ProcessRestart { ip } | A rebooted process boots again from its factory. | runner/process_manager.rs |
CpuFree { ip }, CpuWake { id } | The CPU model released a core or a deferred wake. Only with Chaos::Cpu. | sim/cpu.rs, see CPU Time |
Shutdown | Wake every task for orderly termination. | the orchestrator |
Every storage operation is Pending first and completes through its own event. Every network bind, connect and accept is a delayed operation too. Only established stream I/O can complete on the current poll, when bytes or capacity are already there.
Faults in the Timeline
Every fault the engines inject is also an event in the captured timeline, under the name sim_fault with a kind field. The runner merges SimWorld::take_faults() into the timeline after each step, with source = "sim". The kinds mirror the fault surfaces: partition_created, partition_healed, interface_clogged, random_close, black_hole, bit_flip, storage_read_fault, storage_write_fault, storage_sync_fault, storage_disk_failure, storage_crash, storage_wipe, process_graceful_shutdown, process_force_kill, process_restart and the directed partition pairs.
A workload reads them in check() through ctx.observability(), and an invariant reads them with a cursor:
#![allow(unused)]
fn main() {
use moonpool_sim::{SIM_FAULT_EVENT_NAME, TraceQuery};
for e in q.since(SIM_FAULT_EVENT_NAME, &self.cursor) {
if e.str("kind") == Some("process_force_kill") {
// the process at e.str("ip") just died
}
}
}
This is often the fastest route to a cause: the fault that preceded the violation is a named row with a simulated timestamp, not a line to infer from symptoms.
Reading Backwards
When an assertion fails, the question is what caused this. The trace answers it, read from the failure toward the start.
- Find the first
[ASSERTION FAILED]line. Later ones are usually cascade from the same cause. - Look at the handful of events before it. One of them is the trigger: a
Deliverythat landed a stale message, aTimerthat fired a timeout, anOperationReadythat let a connect race complete, aProcessForceKillthat dropped in-memory state. - Ask which component requested that schedule, and follow the chain back.
Suppose the conservation invariant fires at simulated time 12.4s. The previous sim_fault row is a process_force_kill of 10.0.1.2 at 12.1s with cause crash. The Delivery just before the violation is on a connection the workload opened to 10.0.1.2 at 11.9s. The server answered a get from an empty map because the crash emptied it. Now you have the cause, and it is in the server, not the workload.
Same-time events are FIFO by sequence number. If two completions seem to have swapped order between a passing and a failing seed, look for a changed schedule_at or schedule_after call, or a draw inserted before it.
The RNG Call Count
Every random decision consumes one or more draws on the single simulation stream. moonpool_sim::rng_call_count() at any point is a precise fingerprint of “where we are”. Two runs of one seed that agree through draw 847 and differ at draw 848 diverged in the code that made draw 848. Reproducing with FixedCount uses this with set_rng_breakpoints to bisect non-determinism, and exploration uses the same count to anchor its recipes.
Infrastructure vs Application Events
Not every event matters to an investigation. PartitionRestore, SendPartitionClear, RecvPartitionClear and ProcessRestart are infrastructure events: they maintain simulation state and do no application work. The runner uses this distinction to decide when a seed may end once every workload has returned. When reading a trace, skip them and focus on Delivery, OperationReady, Timer, Storage and the process kills.
Practical Tips
Start narrow. Run the pinned seed at the default level first, read the [ASSERTION FAILED] lines and the sim_fault rows. Widen to trace_level(LevelFilter::TRACE) only when the rows do not explain it.
Print simulated time. SimTime as the subscriber’s timer, as Events and Invariants shows. Wall-clock timestamps on a simulated run are all the same millisecond.
Count backwards. The cause is usually within the ten events before the failure, not two hundred earlier.
Compare two seeds. Run a passing seed and the failing seed at the same level and diff the logs. The first divergence is where the failing path begins.
Emit your own events. A tracing::info!(key = %key, "kv_set_committed") in the workload is both the invariant’s input and your breadcrumb in the trace.
Common Pitfalls
- Pinning a Seed Without a Count
- Forgetting the
?on.tags() - Attrition Without a Chaos Window
- An Instance Workload Under a Rerun
- A Workload That Never Returns
- Direct Tokio Calls
- Wrong Runtime Flavor
- Unordered Collections
- A Variable Number of Draws per Step
- Metrics Steered by the Wall Clock
- Using
unwrap() - Holding a Lock Across
.await ?Sendand!SendTypes- Delayed Operations Need the Scheduler
- Awaiting
until_stalled()From a Task - State Changes Without Events
- Moving Resource State Back Into the World
A list of mistakes we have made ourselves when building simulations with Moonpool. Each one has a symptom, a cause and a fix, so you can recognise it in one glance.
Pinning a Seed Without a Count
Symptom: you called set_debug_seeds(vec![seed]), and the run goes on for hundreds of seeds.
Cause: set_debug_seeds only supplies the first seeds. The default UntilCoverageStable control keeps drawing fresh ones until the plateau.
Fix: add set_iterations(1), or set_iterations(seeds.len()) for a list.
Forgetting the ? on .tags()
Symptom: a type error after .tags(..): Result<SimulationBuilder, _> has no method workload.
Cause: .tags() returns Result<Self, SimulationError> because it fails with InvalidState when no .processes() or .cluster() call precedes it.
Fix: .tags(&[..])? in a function that returns Result, or .expect("tags follow a group") in a test.
Attrition Without a Chaos Window
Symptom: run() returns Err(InvalidConfiguration("1 Chaos::Attrition regime(s) registered without SimulationBuilder::chaos_duration ...")).
Cause: fault injectors, the built-in attrition and outages included, run only inside the chaos window. A builder without chaos_duration would never run them, so it is refused instead of silently passing.
Fix: add .chaos_duration(Duration::from_secs(..)). The same check applies to fault_factory.
An Instance Workload Under a Rerun
Symptom: run() returns Err(InvalidConfiguration("exploration runs a seed more than once and requires fresh workloads ...")), or the determinism canary trips on every seed.
Cause: .workload(instance) reuses one value across runs. Exploration and check_determinism() run a seed more than once and need fresh state each time.
Fix: .workload_factory(|| Box::new(..)) or .workloads(count, |i| Box::new(..)).
A Workload That Never Returns
Symptom: the log says DEADLOCK detected on iteration .. with seed .. after a long simulated run, and the campaign stops.
Cause: run() loops forever, or waits on a detached task that re-arms a timer every tick. The stall guards request shutdown after run_time_budget (one simulated hour) and declare a deadlock a budget later. A zero-duration sleep loop trips the same-time guard after 100,000 events.
Fix: do N operations or sleep for a fixed simulated duration, then return Ok(()). Honour ctx.shutdown() in long loops. Raise run_time_budget only for a legitimately long simulation.
Direct Tokio Calls
Symptom: a tokio::spawn panics because no runtime is running, a tokio::time::sleep never wakes, or a seed stops reproducing.
Cause: there is no tokio runtime inside a simulation. tokio::time::sleep, tokio::time::timeout, tokio::spawn and tokio::select! bypass the simulated clock, the executor and the single random stream.
Fix: ctx.time().sleep(), ctx.time().timeout(), ctx.task().spawn_task() and moonpool_sim::select!. In production the same calls run on TokioProviders.
Wrong Runtime Flavor
Symptom: a #[tokio::main] or #[tokio::test] around the builder, and results differ between runs.
Cause: Moonpool runs on one OS thread for determinism. Wrapping it in a tokio runtime hands scheduling to tokio: redundant at best, real parallelism at worst, even though the traits are Send-bounded.
Fix: a plain #[test] or fn main. SimulationBuilder::run() drives its own executor. Standalone code uses Executor::new(seed) plus executor.block_on(..), as The Deterministic Executor shows. LocalSet, spawn_local and build_local are gone from the model.
Unordered Collections
Symptom: a seed fails once in ten runs, or the canary reports Diverged.
Cause: HashMap and HashSet iterate in an order that varies between runs. If that order picks which account to process or which message to send, the seed no longer controls the schedule.
Fix: BTreeMap and BTreeSet, or collect into a Vec and sort. The workspace’s clippy.toml lists both hash types as disallowed, so this class of leak fails CI.
A Variable Number of Draws per Step
Symptom: an exploration recipe replays to a different timeline than the one that found the bug.
Cause: a loop that resamples “until the operation is enabled” consumes a different number of draws on each seed. Exploration anchors its breakpoints on the RNG call count, so the anchor lands in a different place.
Fix: draw once and fold the result into the enabled subset, as Swarm the Alphabet shows. Keep every per-step draw count fixed.
Metrics Steered by the Wall Clock
Symptom: a metric query gives different numbers for the same seed.
Cause: a histogram observed from Instant::now(). The wall clock is not deterministic.
Fix: record durations from ctx.time(). Metric values are reported, never used to steer the run, as Application Metrics explains.
Using unwrap()
Symptom: a panic that hides the assertion that would have named the real failure.
Cause: in simulation a panic is an uncontrolled crash of the task, not a clean error report.
Fix: Result<T, E> with ? everywhere. For RwLock poison use .expect("RwLock poisoned: prior task panicked"), since poisoning means a prior panic already happened.
Holding a Lock Across .await
Symptom: “future is not Send” pointing at the spawn site, not at the lock.
Cause: spawned futures must be Send, and a std::sync::MutexGuard is not.
Fix: scope the guard tightly. Lock, read or mutate, drop the guard, then .await.
#![allow(unused)]
fn main() {
// Bad: the guard lives across the await.
let guard = state.lock().expect("RwLock poisoned: prior task panicked");
network.send(&guard.payload).await?;
// Good: copy out, drop the guard, then await.
let payload = {
let guard = state.lock().expect("RwLock poisoned: prior task panicked");
guard.payload.clone()
};
network.send(&payload).await?;
}
?Send and !Send Types
Symptom: the compiler rejects #[async_trait(?Send)] on a Process, or refuses a spawned future that captures an Rc or RefCell.
Cause: Process, Workload and FaultInjector carry Send + Sync + 'static supertraits so customer code can share state through Arc<RwLock<..>> and DashMap.
Fix: plain #[async_trait] on dyn-stored impls. Arc<RwLock<..>> or Arc<AtomicX> for shared state. Provider traits use native async fn with -> impl Future<..> + Send and need no attribute.
Delayed Operations Need the Scheduler
Symptom: a low-level provider test awaits a storage write or a network connect and hangs.
Cause: storage read, write, sync and set-length return Pending and complete through a scheduled event. Bind, connect and accept are delayed too. A test that polls the future without stepping SimWorld never completes it.
Fix: prefer SimulationBuilder::run(), which interleaves executor drains and scheduler steps. For a provider test, drive the future and the world together on Executor::block_on with the drive helper from the root AGENTS.md, as Storage Faults shows.
Awaiting until_stalled() From a Task
Symptom: a spawned task awaits executor::until_stalled() and the run wedges.
Cause: until_stalled() is the driver’s drain: it waits until every runnable task has run, including the caller. The orchestrator uses it after each sim.step().
Fix: a task that needs to yield awaits ctx.task().yield_now(). Leave until_stalled() to the driver loop.
State Changes Without Events
Symptom: an invariant misses a bug, or reports a violation the workload never saw.
Cause: invariants read plain tracing events captured into the timeline. A state change with no event is invisible to them.
Fix: emit an INFO event with a constant message for every change an invariant must see. The same line serves production observability.
#![allow(unused)]
fn main() {
self.model.record_commit(slot, value);
tracing::info!(slot, value, "commit");
}
Moving Resource State Back Into the World
Symptom: a borrow checker fight inside SimWorld, or a waker that re-enters a component under the world lock.
Cause: SimWorld coordinates the scheduler and the lifecycle. Network state belongs to NetworkSimulation, storage state to StorageEngine. Reaching through SimInner to mutate a resource and schedule directly couples ownership again.
Fix: add a targeted component transition that returns ordered schedule and cancel effects, fault records and a WakeBatch. The world applies them and wakes after releasing its lock. Never wake while holding it: a waker may poll immediately and re-enter the same component.
Discovering Properties
You know the fifteen macros. You know buggify! and hint!. The hard question with a fresh process and workload is not how to assert. It is what to assert, and where a fault would hurt most.
One read through the code finds the obvious properties. The ones that catch real bugs hide from a single viewpoint. The fix is to read the same code several times, each time through one lens.
The Attention Focus Pattern
The idea comes from Antithesis’s property-discovery work. Instead of one pass with a notebook, make eight passes. Each pass looks for one failure family and ignores the rest. Each pass is an attention focus.
Why does it work? Different bugs live in different mental models. A reader thinking about crash recovery does not notice a check-then-act race. A reader thinking about races does not notice an unsynced write. The discipline is the repetition: eight narrow readings beat one wide one.
Eight Focuses
Each focus has a question, the moonpool tools that test it, and the assertion it usually produces.
1. State integrity. What must survive a reboot? Which numbers only go up? Which derived state can drift from its source? Tools: assert_always! on monotonic values, a StateHandle model published from the workload and compared in check, and an invariant when the property spans nodes. The leader-election test is this focus: one leader per term.
2. Concurrency. Moonpool runs one OS thread, but every .await is an interleaving point. Two workloads hit the same key. A task reads an Arc<RwLock<..>>, yields, then writes. Tools: assert_always! on read-after-write from each workload’s view, and swarm_operations() so the operation mix differs per seed.
3. Crash recovery. A process factory returns a blank instance. In-memory state is gone, and an unsynced write may be torn. Tools: Chaos::Attrition for reboots, SimContext::crash_self for a crash the code chooses, and hint!("label").await in shipped code at the moment that matters. moonpool-journal names three such moments inside one commit: entries written, not synced, records written, not synced, batch synced, metainfo stale. The simulator decides whether to strike each one. Assert with assert_sometimes! that recovery ran, and assert_always! that it rebuilt the right state.
4. Network faults. A call without a timeout. A retry that is not idempotent. A cached leader address after a partition heals. Tools: Chaos::Network(ChaosMode::Swarm) with a network_fault_mask, and the sim_fault timeline to correlate. Assert with assert_always! that the applied count equals the acknowledged count, and assert_reachable! on the retry branch.
5. Timing and scheduling. An election timeout that overlaps a heartbeat timeout. Logic that assumes a timer fires before a message arrives. Tools: buggify! to stretch a delay, moonpool_sim::select! so branch order is seeded, Chaos::GrayFailure and SimContext::slowness for a slow machine. Assert with assert_sometimes! that the late branch won at least once.
6. Resource boundaries. A queue that grows forever under load. No backpressure. Tools: the numeric macros. assert_always_less_than! guards the bound. Its assert_sometimes_greater_than! twin makes the explorer push toward it. SystemProvider counters and application metrics show the shape of the pressure.
7. Protocol contracts. A doc comment says “returns an error if missing” and the code returns a default. An ordering rule between two RPCs that nothing checks. Tools: assert_always! at the boundary, and assert_unreachable! on the branch the contract says cannot happen.
8. Lifecycle transitions. A request arrives before setup finished. Graceful shutdown drops in-flight work. State is published before it is valid. Tools: RebootKind::Graceful with a grace period, Chaos::Outage for a correlated group outage, assert_sometimes_each!("phase", [("phase", phase as i64)]) to prove every phase was entered.
Running the Passes
Two ways work.
Sequential. Walk the focuses as a checklist. Read the whole process once per focus. Do not skip a focus because an earlier pass “already covered” that function. The same line looks different under crash recovery and under concurrency, and that difference is the point.
Ensemble. Give each focus to a separate reviewer, human or AI agent, with only that focus. Merge the results. A property found by three reviewers is high confidence. A property found by one is the kind a single pass would have missed, and often the best catch of the exercise.
Either way, write each finding down the same way:
- Location: file and line range.
- Macro:
assert_always!,assert_sometimes!,hint!,buggify!, and so on. - Message: unique, constant, under 64 bytes.
- Rationale: the bug this catches.
- Provenance: which focus found it.
Then implement, run with the default UntilCoverageStable, and read the report. A sometimes that stays at 0% is a finding too: chaos never reached that branch, so either the fault mix is wrong or the branch is dead.
A Short Example
Take a key/value process that accepts writes over RPC and persists them. One pass adds assert_always!(stored == written, "read-after-write") and stops.
The focuses add four more:
- State integrity: the process keeps a write counter.
assert_always!(new > old, "write counter never regresses"). - Crash recovery: writes land in a map and flush to disk on a timer. Put
hint!("kv: written, not flushed").awaitbetween the two. Addassert_reachable!("kv: recovery replayed the flush log"). - Concurrency: two workloads write the same key.
assert_always!on last-write-wins from each workload’s view. - Network faults: the client retries on timeout and the write is not idempotent.
assert_always_less_than_or_equal_to!(applied, acknowledged, "a retry never double-applies").
The last one is the production outage. A single pass would have found the first.
Properties are not static. Each time a seed fails, ask which focus would have predicted it, and add that focus’s missing assertion before you fix the bug. The assertion stays. The bug does not come back.
Using moonpool-sim Standalone
- The Technical Foundation
- Proof: Real HTTP Over Simulated TCP
- Spawning Inside a Simulation
- What This Means For You
moonpool-sim is a standalone simulation engine. It does not ask you to adopt an actor system or an RPC framework. Provider traits, chaos injection, assertions and fork-based exploration work with the protocol your application already speaks: raw TCP, HTTP/1 through hyper, axum behind hyper, tonic gRPC over h2, or a service whose database boundary is a trait with an in-memory fake behind it.
Most teams do not design a new networking stack. They run services behind a load balancer, call databases, and ship features. Moonpool takes control of the runtime boundaries (time, tasks, sockets, randomness, files) and leaves the application code recognizable.
The Technical Foundation
One trait bound makes this possible. It lives in moonpool-core:
#![allow(unused)]
fn main() {
pub trait NetworkProvider: Clone + Send + Sync + 'static {
type TcpStream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
// bind, connect
}
}
AsyncRead and AsyncWrite here are the runtime-agnostic futures::io
traits. SimTcpStream implements them, and so does the Compat-wrapped
tokio::net::TcpStream that TokioNetworkProvider hands out in production.
Any library that reads and writes through trait-based I/O can therefore sit on
top of a simulated stream. hyper 1.x defines its own rt::Read and
rt::Write traits, and moonpool_hyper::HyperIo implements them over the
futures-io shape. One thin adapter, and hyper, axum, tonic and tower run
unchanged.
This is a design choice, not luck. The provider traits match the shape the tokio ecosystem already uses so that existing libraries need no fork.
Proof: Real HTTP Over Simulated TCP
The integration test in
crates/moonpool-sim/tests/hyper_http.rs
runs an unmodified hyper HTTP/1.1 server over simulated TCP with network
chaos. The server process is short:
#![allow(unused)]
fn main() {
#[async_trait]
impl Process for HyperServer {
fn name(&self) -> &'static str { "server" }
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let listener = ctx.network().bind(ctx.my_ip()).await?;
// moonpool's select!, not tokio's. `biased;` because shutdown is a
// guard on the accept, not a peer racing it.
let (stream, _addr) = moonpool_sim::select! {
biased;
result = listener.accept() => result?,
() = ctx.shutdown().cancelled() => return Ok(()),
};
// SimTcpStream is futures-io; HyperIo presents it as hyper IO.
let io = HyperIo::new(stream);
hyper::server::conn::http1::Builder::new()
.serve_connection(io, service_fn(handle_request))
.await?;
Ok(())
}
}
}
From hyper’s point of view nothing changed. The HTTP parser, chunked encoding, keep-alive logic and content-length checks all run for real, over a network that delays, drops and corrupts bytes.
The client side mirrors it: ctx.network().connect(addr), wrap the stream in
HyperIo, hand it to hyper::client::conn::http1::handshake. The result is
real request and response cycles over a network that tries to break them.
HTTP/2 asks more of the runtime. It spawns tasks and reads a clock.
The hyper Stack shows what moonpool-hyper supplies
for that, and what it gives you for gRPC.
Spawning Inside a Simulation
The simulation is single-threaded by construction. Every iteration runs on the deterministic executor, so every spawned task runs on the one OS thread that steps the event queue. Determinism depends on it.
The types that cross trait boundaries are Send + 'static: the provider
traits, SimTcpStream, Process, Workload and the SimContext you receive.
Application state can use Arc<RwLock<…>>, DashMap or Arc<AtomicBool>
without ceremony. There are two ways to spawn:
#![allow(unused)]
fn main() {
// Through the provider seam. The same call lands on tokio in production
// and on the deterministic executor in simulation.
ctx.task()
.spawn_task("serve-one", async move { serve_one(stream).await })
.detach();
// Directly on the executor, for test-only code.
let handle = moonpool_sim::executor::spawn("serve-one", async move {
serve_one(stream).await
});
}
Prefer ctx.task().spawn_task(name, future). The name shows up when you debug
a seed, and the code stays runnable on TokioProviders later.
Do not use tokio::spawn, tokio::task::spawn_local or a LocalSet.
There is no tokio runtime inside a simulation. A bare tokio::spawn panics,
and spawn_local has no LocalSet to attach to. When you reach for a !Send
workaround, you have left the supported path.
One case still bites. Some third-party connection futures are !Send for
reasons unrelated to moonpool. hyper’s HTTP/1 Connection is the canonical
example: its state machine holds internal Rc<…> cells. You cannot spawn it
on any Send-bounded executor, moonpool’s included. The fix is to drive it
inline next to your other work:
#![allow(unused)]
fn main() {
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
// hyper's HTTP/1 connection future is !Send by its own design.
// Race it against the requests instead of spawning it.
moonpool_sim::select! {
biased;
result = send_requests(&mut sender) => result?,
() = conn => {}
}
}
That is a hyper HTTP/1 decision, not a moonpool constraint. h2 connection
futures, axum handlers, tonic services and tower middleware are all Send.
Spawn them freely.
What This Means For You
If your application talks through trait-based I/O (hyper, axum, tonic and most of the ecosystem do), you can simulate it. The procedure is:
- Define a
Processthat binds a listener and serves connections. - Define a
Workloadthat connects, sends requests and checks the answers. - Replace each external dependency with a trait and a fake (Where to Draw the Line).
- Wire them together with
SimulationBuilder. - Run many seeds with chaos enabled.
Wiring a Web Service walks through all five steps with axum. No new programming model is required. Your existing handlers run over a network that tries to break them.
Where to Draw the Line
- The Partial Failure Problem
- Fakes Give You Control
- The Fidelity Spectrum
- Per-Dependency Guidance
- Rules of Engagement
- The 80% Argument
When someone says “fake your dependencies”, it sounds like cutting corners.
Real databases catch real bugs. Test containers give you the real thing. A
BTreeMap that pretends to be Postgres looks like a compromise.
That instinct is wrong. Fakes are not a compromise. They are more powerful than real dependencies, because you control their failures.
The Partial Failure Problem
A test container gives you binary failure. The whole service is up, or the whole service is down. Docker kills the container, your test sees “connection refused”, and you verify that your retry logic works.
Production failures are never binary. Kafka loses partition 3 while partitions 1, 2 and 4 stay healthy. A Postgres replica lags 800 ms behind the primary while the primary is fine. One Redis shard runs out of memory while five others serve traffic. Your S3 bucket returns 503 on 2% of PUTs while GETs succeed at full speed.
These partial failures are where bugs hide. The request that reads from the lagging replica and writes to the healthy primary. The consumer that rebalances and loses its offset for exactly one topic. The cache lookup that fails for one key prefix while the rest of the keyspace works.
A container cannot produce these failures. It is a black box. You can start it or stop it. You cannot reach inside and make partition 3 fail while partition 4 succeeds.
Fakes Give You Control
A trait-based fake controls the failure surface at any granularity. Your
MessageBroker fake can return Ok for partition 1 and Err for partition 3
in the same call. Your Database fake can add 200 ms of latency on reads from
replica 2 while replica 1 answers at once.
Combined with moonpool’s buggify macros, a fake becomes a probabilistic fault injector. Every operation has a chance to fail, and the chance is drawn from the seeded RNG. When a seed fails, you replay it and get the same sequence of partial failures.
#![allow(unused)]
fn main() {
impl Store for InMemoryStore {
fn create(&self, name: &str) -> Result<Item, StoreError> {
// Active at this site in about half the seeds, then fires at 25%
// per call. A real Postgres is up or down; this fake can fail
// one write and let the next read succeed.
if moonpool_sim::buggify!() {
return Err(StoreError::WriteFailed("buggified".into()));
}
// normal path
}
}
}
Three macros cover most fakes. buggify!() fires at 25% once the site is
active. buggify_with_prob!(0.05) sets the per-call rate. buggify_named!("replica-lag", 1.0)
keys the site by a label instead of its file:line, so a harness can switch
that failure family on or off per seed with set_activation. That is how a
single fake joins swarm testing: each
seed enables a random subset of failure families, and the seeds that fail tell
you which combination matters.
The macros come from moonpool-buggify, a crate with zero dependencies. Outside
a simulation every site evaluates to false with no side effects. A fake that
ships in a production binary (a local development mode, for example) costs
nothing. Buggify covers the activation
model in detail.
The Fidelity Spectrum
Not every dependency needs the same fidelity. Think of fakes on a spectrum:
| Level | What it does | Use it for |
|---|---|---|
| No-op | Returns Ok(()) for everything | Logging, metrics, tracing facades |
| In-memory | BTreeMap storage, VecDeque queues, correct behavior, no persistence | Most unit and integration tests |
| Fault-injectable | In-memory plus buggify sites on each operation | Most simulation fakes |
| Full simulation | The dependency runs as a Process on the simulated network | Your own services, consensus protocols |
Most fakes live on the third row. The fourth row is for components where the network interaction is the behavior under test.
Per-Dependency Guidance
Network (HTTP, gRPC, TCP): simulate with moonpool. Real HTTP parsing, real serialization, simulated transport. Your handlers run unchanged.
Database: a trait with a BTreeMap behind it. Model the operations your
code uses (CRUD, transactions, queries by index). Inject failures per
operation. Do not simulate SQL parsing.
Message brokers: a trait with per-partition control. One VecDeque<Message>
per partition and a buggify site per partition. Model consumer group
rebalancing only if your code depends on it.
External HTTP APIs: canned responses with injectable failures. Your Stripe
client fake returns a known charge object, and buggify_with_prob!(0.1) turns
it into a 429 or a timeout.
Cache: a trait with a BTreeMap. Inject evictions and connection failures.
Local disk: not a fake. Write against StorageProvider and let the
simulator supply the disk, crashes included. See
Storage Faults.
Rules of Engagement
BTreeMap, not HashMap. Iteration order must be the same on every run.
A HashMap iterates in a different order per process, and that makes the
simulation non-deterministic.
Send + Sync state. axum requires State to be Send + Sync, and every
moonpool trait is Send-bounded. Use Arc<RwLock<BTreeMap<…>>>, not
Rc<RefCell<…>>. The executor runs on one OS thread, so the lock is never
contended. The bounds make the type fit, not the thread count.
No wall clock, no OS entropy inside the fake. A fake that calls
Instant::now() or rand::random() breaks replay. Take time and randomness
from the providers, or from the buggify macros.
The Oxide convention. Oxide’s omicron
and crucible keep fakes in a
fakes/ module next to the real implementation. The trait and the fake ship
together. When the trait changes, the fake changes in the same PR.
The 80% Argument
A fake that covers 80% of a dependency’s behavior, with determinism and fault injection, is strictly better than a container that covers 100% with no control over failures.
Containers are non-deterministic. They take seconds to start. They need Docker. They break across versions. They cannot produce partial failures. A failing test gives you a log and a guess.
A fake compiles with your project. It runs in microseconds. It reproduces from a seed. It produces failures that containers cannot. The 80% you model covers the behavior your code depends on.
The other 20% belongs in separate integration tests against real infrastructure, run less often. Those tests verify that the fakes are faithful. The simulation tests, run on every commit, verify that your code handles failure. When an integration test reveals a failure mode the fake does not model, add it to the fake. When a simulation finds a bug under partial failure, confirm the fix against the real thing. The two kinds of tests improve each other.
Wiring a Web Service
- Step 1: The Store Trait
- Step 2: The InMemoryStore
- Step 3: The Axum Router
- Step 4: The Process
- Step 5: The Workload
- Step 6: Wire It Together
- Going Further
Theory is cheap. Here is a complete worked example: an axum web service inside
moonpool-sim, with a fault-injectable store, network chaos and assertions. The
full source is
crates/moonpool-sim-examples/src/axum_web.rs,
and cargo xtask sim run axum runs it.
Step 1: The Store Trait
Every dependency boundary starts with a trait. This one models item persistence:
#![allow(unused)]
fn main() {
pub trait Store: Send + Sync + 'static {
fn create(&self, name: &str) -> Result<Item, StoreError>;
fn get(&self, id: u64) -> Result<Option<Item>, StoreError>;
}
}
Send + Sync + 'static because axum requires State to be Send + Sync. In
production, Postgres or SQLite implements this trait. In simulation, a
BTreeMap does.
The methods are synchronous. A fake that never does I/O has no reason to be async. If your production trait is async, that works too.
Step 2: The InMemoryStore
BTreeMap for deterministic iteration order. AtomicU64 for ID generation.
RwLock because axum needs Send + Sync.
#![allow(unused)]
fn main() {
pub struct InMemoryStore {
items: RwLock<BTreeMap<u64, Item>>,
next_id: AtomicU64,
}
impl Store for InMemoryStore {
fn create(&self, name: &str) -> Result<Item, StoreError> {
// Models disk full, replication lag, constraint violations.
// A Postgres container cannot fail one write and pass the next.
if moonpool_sim::buggify!() {
return Err(StoreError::WriteFailed("buggified write failure".into()));
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let item = Item { id, name: name.to_string() };
let mut items = self.items.write()
.expect("RwLock poisoned: prior task panicked");
items.insert(id, item.clone());
Ok(item)
}
fn get(&self, id: u64) -> Result<Option<Item>, StoreError> {
// Reads fail less often than writes in practice.
if moonpool_sim::buggify_with_prob!(0.05) {
return Err(StoreError::ReadFailed("buggified read failure".into()));
}
let items = self.items.read()
.expect("RwLock poisoned: prior task panicked");
Ok(items.get(&id).cloned())
}
}
}
The buggify sites are the whole point. The fake fails creates at 25% and gets at 5%, once the site is active for the seed. That is the asymmetric failure a real database shows and a container cannot.
Step 3: The Axum Router
Standard axum. Nothing here is moonpool-specific:
#![allow(unused)]
fn main() {
pub fn build_router(store: Arc<dyn Store>) -> axum::Router {
axum::Router::new()
.route("/health", get(health))
.route("/items", post(create_item))
.route("/items/{id}", get(get_item))
.with_state(store)
}
}
The handlers take State(store): State<Arc<dyn Store>> and return ordinary
axum responses. create_item returns 201 on success and 500 when the store
fails. get_item returns 200, 404 or 500. If you already have an axum app,
your router goes here unchanged.
Step 4: The Process
A Process is the system under test. It runs on a simulated node:
#![allow(unused)]
fn main() {
#[async_trait]
impl Process for WebProcess {
fn name(&self) -> &'static str { "web" }
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
use futures::stream::{FuturesUnordered, StreamExt};
let store = InMemoryStore::new();
let app = build_router(store);
let listener = ctx.network().bind(ctx.my_ip()).await?;
// hyper's HTTP/1 serve_connection future is !Send, so no
// Send-bounded spawn accepts it. Drive every in-flight
// connection inline from one task.
let mut connections = FuturesUnordered::new();
loop {
// Not `biased;`: a new accept and progress on an existing
// connection are peers. The seed decides which wins.
moonpool_sim::select! {
accept = listener.accept() => {
let (stream, _addr) = accept?;
// Vectored writes on: the sim delivers each IoSlice
// separately, each with its own chance of chaos.
let io = HyperIo::new(stream).with_vectored_writes(true);
let service = TowerToHyperService::new(app.clone());
connections.push(async move {
if let Err(e) = hyper::server::conn::http1::Builder::new()
.serve_connection(io, service)
.await
{
tracing::warn!("hyper error (expected under chaos): {e}");
}
});
}
Some(()) = connections.next(), if !connections.is_empty() => {}
() = ctx.shutdown().cancelled() => return Ok(()),
}
}
}
}
}
Two points. First, the process calls
hyper::server::conn::http1::Builder::serve_connection, not
axum::serve(). axum::serve takes a tokio::net::TcpListener, so it cannot
accept the simulated listener. serve_connection takes anything that
implements hyper’s IO traits. HyperIo from moonpool-hyper is that adapter,
and TowerToHyperService turns the axum router (a tower service) into the
hyper service the builder wants. The example needs neither tokio-util nor
hyper-util.
Second, FuturesUnordered instead of spawning. The blocker is hyper’s
HTTP/1 state machine, which holds Rc<…> cells and so is !Send. It cannot
cross a Send-bounded spawn, but it polls fine from the accept loop. The
select! is moonpool’s and stays in its default form on purpose. Accepting a
connection and progressing one are both ready in the same step, and different
seeds explore both orders.
Step 5: The Workload
The workload is the test driver. It connects, sends requests and checks the answers:
#![allow(unused)]
fn main() {
#[async_trait]
impl Workload for WebWorkload {
fn name(&self) -> &'static str { "client" }
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let server_ip = peer_ip(ctx, "web")?;
for round in 0..5 {
// Stop cleanly when the orchestrator ends the run mid-round.
let Some(result) =
unless_shutdown(ctx, self.send_round(ctx, &server_ip, round)).await
else { break };
if let Err(e) = result {
// Connection drops and reboots are expected under chaos.
moonpool_sim::assert_sometimes!(true, "request_round_failed");
tracing::warn!(round, "round failed (expected under chaos): {e}");
}
}
Ok(())
}
}
}
peer_ip and unless_shutdown are small helpers from the examples crate.
unless_shutdown races a future against ctx.shutdown().cancelled() with a
biased; select, so it draws no scheduling randomness.
Inside send_round, the workload opens a hyper client connection. The client
has the same !Send problem: hyper::client::conn::http1::handshake returns a
conn driver future that must be polled, or every request stays pending
forever. The workload races the driver against the requests:
#![allow(unused)]
fn main() {
let io = HyperIo::new(stream).with_vectored_writes(true);
let (sender, conn) = hyper::client::conn::http1::handshake(io).await?;
let mut client = HttpClient { sender, host: server_ip };
moonpool_sim::select! {
biased;
result = async {
Self::check_health(&mut client, round).await?;
Self::create_and_read_item(&mut client, round).await?;
Self::check_not_found(&mut client, round).await
} => result?,
// The connection died first: give up the round.
() = conn => {}
}
}
The assertions split into two kinds:
assert_always!for invariants:/healthreturns 200, a read after a successful write returns the same item, a missing item returns 404 or 500.assert_sometimes!for coverage:item_created_successfully,store_write_failed,store_read_failed,request_round_failed.
The assert_sometimes! calls are how moonpool knows the error paths ran. If
store_write_failed never fires across 50 seeds, the chaos configuration is
wrong, and the report says so.
Step 6: Wire It Together
The sim-axum-web binary does the wiring:
#![allow(unused)]
fn main() {
let report = SimulationBuilder::new()
.processes(1, || Box::new(WebProcess))
.workload(WebWorkload)
.set_iterations(50)
.run()
.expect("simulation configuration is valid");
finish_or_exit_on_failure(&report);
}
One web process, one workload, 50 seeds. Each iteration starts fresh: new network, new process, new store, new buggify activation. The default network configuration injects latency and connection faults, so your handlers face network chaos and store chaos at the same time.
When a seed fails, replay it with .set_debug_seeds(vec![seed]) and
.set_iterations(1). Reproducing with FixedCount
shows the full loop.
Going Further
This example runs HTTP/1 by hand. For HTTP/2 and gRPC, moonpool-hyper
supplies the executor and timer hyper needs, plus a reconnecting client channel
and a serve helper. The hyper Stack picks up there.
The hyper Stack: gRPC and HTTP
- Serving: One Call Per Connection
- Connecting: The Channel Owns the Connection
- The Sealed Bounds
- Why Any of This Is Deterministic
- Seeing It Pay Off
The axum chapter put a web service into simulation by handing hyper a simulated stream. That works because HTTP/1 asks almost nothing of the runtime: parse bytes, write bytes, done. HTTP/2 is different. It multiplexes streams over one connection, it pings the peer to notice death, and it runs per-request work concurrently. To do that, hyper needs to spawn tasks and read a clock.
hyper is runtime-agnostic about both. It defines rt::Executor and rt::Timer
traits and asks you to supply them. In production everyone supplies
hyper-util’s tokio versions, which is why the ecosystem looks tokio-only.
Inside a simulation those are the two things we cannot allow. A tokio spawn
never runs on the deterministic executor, and a tokio sleep runs on a wall
clock that no seed controls.
moonpool-hyper supplies the same hooks over the provider traits. It depends
only on moonpool-core, so it brings no production providers and no
simulation of its own. It is generic over P: Providers, and the facade
exposes it as moonpool::hyper behind the hyper feature.
| Type | What hyper asks for | What it uses |
|---|---|---|
HyperExecutor<T> | rt::Executor, spawn internal tasks | TaskProvider |
HyperTimer<T> | rt::Timer, sleeps and now() | TimeProvider |
HyperIo<S> | rt::Read + rt::Write | any NetworkProvider stream |
TowerToHyperService<S> | a hyper Service | your tower service |
KeepAlive | h2 PING settings | shared by both sides |
H2Server<P> | (server plumbing) | the whole provider bundle |
ReconnectingChannel<P, B> | (client plumbing) | the whole provider bundle |
The first five are thin. The last two are where the plumbing that every hyper
user writes by hand gets written once. Two cargo features, client and
server, both on by default, select them. With both off the crate still
builds the adapters and needs no hyper feature at all.
Serving: One Call Per Connection
Here is the gRPC server from
tonic_grpc.rs:
#![allow(unused)]
fn main() {
let server = H2Server::new(ctx.providers()).with_config(H2ServerConfig {
keep_alive: Some(keep_alive()),
vectored_writes: true,
..H2ServerConfig::default()
});
loop {
moonpool_sim::select! {
accept = listener.accept() => {
let (stream, _addr) = accept?;
// A graceful Attrition reboot cancels the token. hyper then
// finishes the RPCs in flight before the connection ends.
let shutdown = ctx.shutdown().clone();
let connection = server.serve_connection_with_shutdown(
stream,
echo.clone(),
shutdown.clone().cancelled_owned(),
);
ctx.task()
.spawn_task("grpc-server-conn", async move { connection.await })
.detach();
}
() = ctx.shutdown().cancelled() => return Ok(()),
}
}
}
echo is the generated EchoServer, an ordinary tower service.
serve_connection_with_shutdown wraps it in TowerToHyperService, builds the
HyperIo, and returns a future that is Send + 'static. It spawns as a normal
provider task. h2 connection futures are Send, unlike hyper’s HTTP/1 state
machine, which is why this is a spawn and not the inline FuturesUnordered
loop of the axum example. serve_connection without a shutdown future does the
same with no drain.
The accept loop stays with the caller. The race between a new connection
and progress on existing ones is a real ordering question. Under the seeded
select! different seeds explore both answers. A helper that hid it would hide
a property worth testing.
H2ServerConfig has three fields:
| Field | Default | Meaning |
|---|---|---|
keep_alive | None | h2 PING interval and timeout. KeepAlive::while_idle is a client knob and is ignored here |
vectored_writes | false | what HyperIo reports to hyper. true makes the sim apply chaos per IoSlice |
drain_timeout | Some(DEFAULT_DRAIN_TIMEOUT), 30 s | how long a graceful drain may run once shutdown is requested |
The drain is bounded. A peer that stops reading cannot hold the connection
future open forever. When the deadline runs out the connection is dropped and
the future resolves to ServeError::DrainTimedOut. Any other bad end is
ServeError::Connection, which wraps hyper’s error. Under chaos that is an
expected outcome, not a bug. The example checks that the drain really happens
with assert_sometimes!(true, "grpc_server_drained_on_shutdown").
Connecting: The Channel Owns the Connection
On the client, tonic::transport::Channel is the thing we cannot use. It calls
tokio::spawn and hard-codes tokio’s executor and timer. Its job is still
needed: connect lazily, reconnect after a loss, multiplex everything over one
connection. ReconnectingChannel does that job over the providers:
#![allow(unused)]
fn main() {
type GrpcChannel = ReconnectingChannel<SimProviders, tonic::body::Body>;
let channel: GrpcChannel = ReconnectingChannel::new(
ctx.providers(),
server_ip.clone(),
ChannelConfig {
connection_timeout: CONNECT_TIMEOUT,
keep_alive: Some(keep_alive()),
vectored_writes: true,
..ChannelConfig::default()
},
);
let echo_client = EchoClient::with_origin(channel.clone(), origin.clone());
}
The channel is a tower Service, so the generated tonic client takes it
directly. One channel serves the whole workload, cloned into as many clients as
you like. When Attrition kills the server mid-round, the next round does not
connect by hand. It finds that the channel rebuilt itself. That is reconnection
under test, not reconnection assumed.
ChannelConfig defaults:
| Field | Default |
|---|---|
initial_reconnect_delay | 100 ms, doubles per consecutive failure |
max_reconnect_delay | 30 s |
connection_timeout | 5 s, applied to the TCP connect and to the h2 handshake |
max_connection_failures | None, retry forever |
keep_alive | None |
vectored_writes | false |
When a workload ends or a peer goes away, close the shared lifecycle:
#![allow(unused)]
fn main() {
channel.close();
assert!(channel.is_closed());
}
close is terminal, idempotent and shared by every clone. It interrupts an
active connect or backoff, drops the live h2 driver and its keepalive work, and
makes parked readiness checks, new requests and in-flight request futures
return ChannelError::Closed. It does not replay RPCs. Only the caller knows
whether an RPC is safe to retry.
ChannelError names the other outcomes: Connect, ConnectTimeout,
Handshake, Request, NotReady and ExhaustedRetries. Through tonic they
arrive as Code::Unknown, because tonic’s h2-aware status mapping lives behind
features the runtime-free core does not enable. The example gives transport
death its own assert_sometimes! so it stays distinct from an
application-level UNAVAILABLE.
At the tower boundary, a failed connect is reserved for one caller:
poll_ready returns Ready(Ok(())) and the following call returns the error.
Tower treats Ready(Err(_)) as terminal for that service instance, so the
channel uses it only for explicit shutdown and an exhausted
max_connection_failures. tonic’s own reconnect service follows the same rule.
The Sealed Bounds
hyper’s h2 builders require Http2ClientConnExec and Http2ServerConnExec.
Those traits are sealed. You cannot implement them. You satisfy them by
supplying an executor that already implements rt::Executor for hyper’s
private future types. Naming them in a generic signature compiles, but nothing
proves the bound holds until a concrete type instantiates it. moonpool-hyper’s
unit tests do that with H2Server<TokioProviders> and
ReconnectingChannel<TokioProviders, Full<Bytes>>. If a wrapper of your own
refuses to compile, this is usually why.
Why Any of This Is Deterministic
Five choices keep h2 behavior reproducible from a seed.
Keepalive runs on provider time. With a timer configured, hyper reads the
clock only through Timer::now(), and HyperTimer answers from the time
provider. A ping interval of 3 seconds is 3 seconds of simulated time. A
connection that dies because a clogged link swallowed the PONG dies at the same
simulated instant on every replay.
Backoff has no jitter. Production jitter desynchronizes a fleet of clients.
Here it would make reconnect timing depend on something other than the seed.
ChannelConfig doubles from initial_reconnect_delay and saturates at
max_reconnect_delay.
Response headers do not read the wall clock. hyper adds a Date header
from SystemTime by default. That timestamp enters the connection’s HPACK
state, so two identical replays can emit different frame sizes and perturb
later network choices. H2Server disables the automatic header. The builder()
escape hatch lets a production caller turn it back on.
Reset streams expire by count, never by wall clock. h2 keeps a locally
reset stream around so late frames can be ignored, and by default it judges
that expiry on std::time::Instant::now() after one real second. A slow
machine would then expire streams at a different simulated instant.
ReconnectingChannel sets reset_stream_duration to Duration::MAX, so the
count cap alone bounds them. hyper exposes no such knob on the server builder,
so a server that resets streams keeps a small dependence on wall time there.
A connection has to earn its reset. The failure count clears only once a
connection has survived initial_reconnect_delay, not when the handshake
completes. Without that rule, a peer that accepts and dies at once reconnects
forever at zero backoff. With it, a flapping peer faces escalating delays,
which is the correct production behavior and a bounded one to simulate.
Seeing It Pay Off
metastable_grpc_retry_storm
builds the smallest gRPC system that shows a metastable failure: a 650 ms
slowdown that pushes an open-loop client into a retry storm the server never
recovers from. It uses H2Server, ReconnectingChannel and the prometheus
adapter, and prints an ASCII time series per seed:
cargo run --release --example metastable_grpc_retry_storm \
--features hyper,prometheus -- --seed 1
Seed 1 storms. Seed 0 recovers. Same code, same load, and the seeded network jitter decides which stable state the system lands in. That experiment works because every timer and every spawn in the stack runs on provider time.
What You’re Testing (and What You’re Not)
Running an axum or tonic service inside moonpool-sim exercises a specific slice of your application. Knowing what sits inside that slice and what sits outside prevents both false confidence and needless doubt.
What You Are Testing
Real handler logic. Your axum handlers and tonic service methods run unchanged. serde serializes and deserializes for real. Routing matches real paths. Middleware runs in order. Extractors parse real requests. protobuf encodes and decodes real frames. A bug in your JSON response shape or in a status mapping shows up in simulation.
HTTP behavior under chaos. Requests cross a simulated TCP connection with injected latency, connection drops, clogs and corrupted bytes. hyper’s HTTP/1 parser and the h2 frame machinery process real wire bytes through a network that tries to break them. Half-closed connections, truncated messages, resets mid-response: all of it runs.
Concurrent request handling. The HTTP/1 example drives every open
connection from one FuturesUnordered. The h2 example spawns one task per
connection and multiplexes streams inside it. Either way, the seeded executor
interleaves handlers differently on every seed, so races on shared state (your
Store fake, your counters) surface.
Error paths. Connection failures, timeouts, store failures from
buggify!, mid-stream aborts. The workload checks that the service answers
with the right status, does not panic, and does not corrupt state.
Recovery after a reboot. With Chaos::Attrition enabled, moonpool kills
and restarts processes. Process::run starts fresh after each reboot. A
service that leaks state across restarts, or fails to rebind its listener, is
found. With Chaos::Outage, a whole process group goes down together.
Reconnection. The ReconnectingChannel rebuilds its connection after the
server dies, with deterministic backoff. The tonic example asserts
grpc_channel_recovered to prove a later round succeeded after an earlier
one lost its connection.
Time-dependent logic. Deadlines, keepalive pings, retry backoff and the
retry storm in metastable_grpc_retry_storm all run on simulated time. A
40-second experiment takes milliseconds, and the timing is the same on every
replay.
What You Are Not Testing
Production startup code. In production, axum::serve() or
tonic::transport::Server binds a real listener and owns the accept loop. In
simulation, you write the accept loop and call serve_connection. A bug in
the production startup path (wrong bind address, a middleware you forgot to
register) is not caught here. Keep that path small and test it once with a
real socket.
Real database execution. The Store fake is a BTreeMap, not Postgres.
Query plans, isolation levels, connection pools, migrations: none of these run.
A query that works against the fake but generates wrong SQL is not caught.
TLS. Simulated streams are plaintext. Handshake failures, certificate
validation and protocol negotiation are not exercised. moonpool-rpc has a
tls feature for its own transport, but hyper over HyperIo carries no TLS.
Real TCP congestion. moonpool-sim models connection-level faults and end-to-end flow control, a fixed byte window per direction that a slow reader fills. It does not model congestion algorithms, window autotuning or retransmission timers. A bug that only appears under a kernel’s congestion behavior is not found. A bug that appears because a writer never expected to block is.
OS resource limits. File descriptor exhaustion, memory pressure, kernel scheduling. The simulation runs in one process with no OS limits. A service that leaks file descriptors looks healthy here.
Wall-clock cost of your code. Simulated time only advances when the code
sleeps or waits on I/O. A handler that burns 50 ms of CPU costs nothing unless
you say so. cpu_charge(duration) from moonpool-buggify names that cost,
and CPU Time and Gray Failures
shows how the simulator turns it into latency and slow machines.
One residual wall-clock read. hyper’s server builder exposes no knob for the reset-stream expiry, so a server that resets h2 streams keeps a small dependence on real time. The client side is fully bounded by count. See The hyper Stack.
The Same Tradeoff Everyone Makes
Every simulation system draws this line. AWS’s ShardStore runs real application logic over simulated network and faked storage. FoundationDB simulates network and disk but not the kernel. TigerBeetle simulates I/O but not the filesystem. Antithesis runs real binaries and controls the hypervisor, not the hardware.
The common thread: simulate the interaction boundaries where failures happen (network, storage, time), run real implementations for computation (parsers, serializers, business logic), and accept that some bug classes need other tests.
Incremental Adoption
You do not have to simulate the whole application on day one. Start with one
module. The trait boundary you draw for simulation (Store, Cache,
MessageBroker) also improves the production architecture. Dependency
injection makes the code testable with or without simulation.
Each module you bring in compounds coverage. The first module finds bugs in its own error handling. The second module, talking to the first under chaos, finds bugs in the interaction. By the third module, the simulation finds bugs you did not know to look for.
The entry cost is one trait and one fake per dependency. The ongoing cost is keeping fakes in step with traits, which the Oxide convention of shipping them together keeps small. The return is deterministic, reproducible, chaos-tested coverage of your real application logic, in milliseconds, on every commit.
Using Providers in Production
- The production backend
- A lean dependency tree
- Fault sites ship, and do nothing
- One gotcha:
now()is a stopwatch - Securing RPC in production
- Where it runs
Every chapter so far has been about testing. The point of writing against the
provider traits is that the code you tested is the code you ship. There is no
separate production version to keep in sync and no #[cfg(test)] fork in
behavior. The function that ran ten thousand seeds under simulation is the
function that now handles real traffic.
Same Code in Production and Simulation
states the rule. This chapter shows how to deploy under it.
The production backend
Simulation hands your code SimProviders. Production hands it
TokioProviders, a bundle of the six real implementations: tokio tasks,
wall-clock time, real TCP, OS randomness, real files, and the kernel’s CPU and
disk counters through SystemProvider. Code generic over P: Providers
accepts either one.
use moonpool::prelude::*;
// The same signature the tests use.
async fn fetch_with_retry<P: Providers>(providers: &P, max_attempts: u32)
-> Result<u32, String>
{
let time = providers.time();
let random = providers.random();
for attempt in 0..max_attempts {
if random.random_bool(0.6) {
return Ok(attempt);
}
// Every sleep goes through the provider, so simulation advances
// logical time instead of waiting.
let base_ms = 10u64 << attempt;
let jitter_ms = random.random_range(0..base_ms.max(1));
time.sleep(Duration::from_millis(base_ms + jitter_ms))
.await
.map_err(|e| e.to_string())?;
}
Err(format!("gave up after {max_attempts} attempts"))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let providers = TokioProviders::new();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let result = runtime.block_on(fetch_with_retry(&providers, 5));
println!("production run finished: {result:?}");
Ok(())
}
The runnable version is
crates/moonpool/examples/retrying_worker.rs.
Its main runs on tokio. Its #[test] drives the same fetch_with_retry
through SimulationBuilder across 50 seeds and asserts failed_runs == 0.
One function, two worlds: cargo run --example retrying_worker and
cargo test --example retrying_worker.
Networking follows the same swap. Code written against NetworkProvider sees
SimTcpStream in a simulation and a real tokio stream in production. An HTTP
or gRPC stack gets the same treatment through moonpool-hyper: H2Server and
ReconnectingChannel take &TokioProviders in production exactly as they take
ctx.providers() in a test.
A lean dependency tree
The simulation runtime and the fork-based explorer have no place in a
production binary. The explorer pulls in libc, fork and mmap, machinery
that exists only to branch timelines during a test. A production build should
never compile it.
Moonpool keeps it out with features. The default (sim plus tokio) is the
full framework, which is what tests use. Production opts out:
[dependencies]
moonpool = { version = "0.8", default-features = false, features = ["tokio"] }
That stanza gives you the provider traits, TokioProviders and
moonpool::select! (tokio’s macro, verbatim, since no sim is linked). Nothing
else. cargo tree -e no-dev on such a build shows no moonpool-sim, no
moonpool-explorer, no moonpool-assertions, no hyper, no prometheus. A
CI job runs that cargo tree on every change and fails the build if any of
them creeps back in.
| Feature | Pulls in | Use it when |
|---|---|---|
tokio | TokioProviders, moonpool::select! | The application runs on real time, tasks, TCP, randomness and files |
hyper | moonpool::hyper: runtime adapters, HyperIo, H2Server, ReconnectingChannel | The application uses hyper, axum or tonic |
prometheus | moonpool::prometheus: PrometheusSource and instrumented handles | A prometheus::Registry you want in the simulation report |
rpc | moonpool::rpc, typed dynamic-endpoint RPC | Processes call each other through moonpool-rpc |
rpc-derive | the #[moonpool_rpc::service] attribute | Generated service interfaces |
rpc-tls | rustls session upgrade (native only) | Server-authenticated RPC sessions |
rpc-jwt | JWT and JWKS verification (native only) | Per-request credential checks |
sim | the simulation runtime and the explorer | Tests, benchmarks, local runs (default) |
Keep sim on in [dev-dependencies] and off in the release build. The tests
run the full simulator, the shipped binary stays lean.
Fault sites ship, and do nothing
Shipped code names its interesting moments. A buggify site marks a rare but
legal path. A hint marks a moment where a crash would be interesting. A CPU
charge names work that costs time. All three come from moonpool-buggify, a
crate with zero dependencies, so production code depends on it directly:
moonpool-buggify = "0.8"
#![allow(unused)]
fn main() {
use moonpool_buggify::{buggify, cpu_charge, hint};
async fn commit(batch: &Batch) -> io::Result<()> {
storage.write_all(batch.bytes()).await?;
storage.sync_all().await?;
// Durable but not acknowledged. A crash here is the interesting one.
hint!("batch durable, not acked").await;
if buggify!() {
// A legal slow path: pretend the ack was delayed.
time.sleep(Duration::from_millis(50)).await?;
}
cpu_charge(Duration::from_micros(2) * batch.len() as u32);
send_ack().await
}
}
In production every one of these is a no-op. buggify! returns false
because no random source is installed. hint! returns a future that resolves
at once because no sink is installed. cpu_charge reads one thread-local and
returns. Removing every site leaves the binary unchanged.
In simulation, moonpool-sim installs its seeded random source at the start
of each run, and its HintSink decides what a fired hint does: nothing, a
survivable fault, or a kill of the owning process, under the seed’s chaos
regime, swarm mask and attrition budget. The code names the moment. The
simulator chooses the fault. Buggify and
hint! cover both sides.
One gotcha: now() is a stopwatch
In simulation, time().now() returns the canonical simulation clock. In
production, TokioTimeProvider::now() returns elapsed time since the provider
was created. It is a monotonic stopwatch, right for measuring durations and
scheduling relative deadlines, and deliberately not a calendar. For a
wall-clock timestamp in a log line or a persisted record, call
std::time::SystemTime at that call site. Keep sleeps, timeouts and backoff on
the provider so they stay testable. time().timer() is the application
clock: equal to now() in production, drifted by clock-skew faults in
simulation.
Securing RPC in production
The simulation runs moonpool-rpc with scripted trust: a FixedUtc clock and
the accept_credentials_over_plaintext and send_credentials_over_plaintext
opt-ins, so it replays every decision without certificates. Production flips
each choice:
- Give the server
SecurityConfig::enforced(verifier).with_clock(SystemUtc). The defaultNoUtcclock refuses every expiring credential. - Enable
rpc-tlsand wrap the transport in the session upgrade, so clients authenticate the server before a credential leaves. - Leave both plaintext opt-ins off.
A deployment that really trusts its network says so with
SecurityConfig::trusted_network().
Admission, Security and Derive
has the details.
Where it runs
The provider traits and the production backend compile broadly. The sim runtime is portable too, with one boundary: the fork-based explorer needs a POSIX process model.
| Target | core traits | TokioProviders | sim runtime | explorer (fork) |
|---|---|---|---|---|
| Linux | yes | yes | yes | yes |
| macOS | yes | yes | yes | yes |
wasm32-unknown-unknown | yes | no | yes, --no-default-features | no |
The simulation engine compiles to wasm32-unknown-unknown because it derives
everything from a seeded RNG, a logical clock and a cooperative scheduler. The
explorer cannot follow: it is built on fork(), waitpid() and MAP_SHARED
memory, which Linux and macOS provide (CI runs the exploration suite on both).
On wasm the simulator runs with the in-process assertion table. You lose
multiverse forking, not correctness checking.
Simulation in the Browser shows it running in a tab.
Migrating Existing Code to Providers
- Thread one bundle from the top
- The call mapping
- Gotcha 1: the I/O traits move to
futures::io - Gotcha 2:
now()is a stopwatch, not a calendar - Gotcha 3: replace every last one
- Name the moments while you are there
- Knowing the swap was safe
You have a service that already works. It sleeps, it spawns, it opens sockets
and files, and somewhere it calls rand. None of that runs under simulation
yet, because every one of those calls reaches straight into the operating
system and the wall clock. The job is to route each of them through a provider,
so that the same function can one day run under SimProviders and replay from
a seed.
This is a refactor, not a rewrite. The code keeps running on real tokio the
whole time, because TokioProviders wraps the exact primitives you replace. You
do not change behavior. You change who you ask for time, tasks, randomness,
sockets, files and system counters.
Thread one bundle from the top
Before you touch a call site, decide how the providers reach your code. The
pattern is one type parameter, P: Providers, threaded from main down the
call graph. Build the bundle once at the top and pass it by reference.
use moonpool::prelude::*;
async fn run<P: Providers>(providers: &P) -> Result<(), MyError> {
// everything non-deterministic goes through `providers`
}
#[tokio::main]
async fn main() -> Result<(), MyError> {
let providers = TokioProviders::new();
run(&providers).await
}
The discipline is to resist the shortcut. Do not call tokio::time::sleep
three layers deep because the provider is not in scope. Plumb it through. A
function that still reaches for a global is a function the simulation cannot
control.
The call mapping
Most of the migration is mechanical. Each primitive has a direct provider equivalent.
| You have | Swap to |
|---|---|
tokio::time::sleep(d).await | providers.time().sleep(d).await? |
tokio::time::timeout(d, fut).await | providers.time().timeout(d, fut).await |
Instant::now() for elapsed time | providers.time().now() (a stopwatch, see below) |
| a lease or heartbeat clock | providers.time().timer() (drifts under simulation) |
tokio::spawn(fut) | providers.task().spawn_task("name", fut) |
tokio::task::yield_now().await | providers.task().yield_now().await |
tokio::select! { … } | moonpool::select! { … } |
rand::random(), rng().random() | providers.random().random() |
rng().random_range(a..b) | providers.random().random_range(a..b) |
rng().random_bool(p) | providers.random().random_bool(p) |
tokio::fs / std::fs | providers.storage().open(path, OpenOptions) plus exists, delete, rename, sync_dir |
tokio::net::TcpListener::bind(addr) | providers.network().bind(addr).await |
tokio::net::TcpStream::connect(addr) | providers.network().connect(addr).await |
getrusage, /proc/stat, /proc/diskstats | providers.system().sample(data_dir) |
Two type changes ride along. time().sleep returns Result<(), TimeError>,
so add a ?. time().timeout returns Result<T, TimeError> instead of
tokio’s Result<T, Elapsed>, so match on TimeError::Elapsed.
moonpool::select! matters more than it looks. tokio’s macro picks a random
start branch from a thread-local RNG that no seed controls. moonpool’s macro is
tokio’s expansion driven by a seeded offset when a simulation is linked, and
tokio’s macro verbatim in the lean production build. Same syntax, including
biased;.
Gotcha 1: the I/O traits move to futures::io
This one surprises people. The tokio network and storage providers wrap
tokio::net::TcpStream and tokio::fs::File in tokio_util::compat::Compat,
which exposes the runtime-agnostic futures::io::{AsyncRead, AsyncWrite, AsyncSeek} traits instead of tokio::io::*. The socket is still a real socket.
Only the extension trait you import changes.
#![allow(unused)]
fn main() {
// Before: tokio's extension traits.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// After: the same method names, from futures.
use futures::io::{AsyncReadExt, AsyncWriteExt};
stream.write_all(b"hello").await?;
let n = stream.read(&mut buf).await?;
}
write_all, read, read_exact and read_to_end all exist on the futures
traits. Seeking comes from futures::io::AsyncSeekExt. The tokio-only
read_buf and AsyncBufRead have no direct equivalent, so a read loop built
on them needs a small restructure into plain read calls. For hyper, axum and
tonic, moonpool_hyper::HyperIo does the adaptation
(The hyper Stack).
Gotcha 2: now() is a stopwatch, not a calendar
providers.time().now() returns elapsed time since the provider was created.
It is right for durations and relative deadlines and useless as a timestamp.
When a log line or a persisted record needs calendar time, call
std::time::SystemTime at that exact site and leave everything that drives
sleeps, timeouts and backoff on the provider.
Using Providers in Production has more.
Gotcha 3: replace every last one
One surviving tokio::time::sleep or rand::rng() is enough to make a future
simulation non-reproducible, and it will not announce itself. Make the sweep
exhaustive:
- Grep the crate for
tokio::,rand::,std::fs,std::time::InstantandSystemTime. - Confirm each remaining hit is outside the deterministic path.
- Replace
HashMapiteration that affects behavior withBTreeMap. - Run the existing tokio tests. They must still pass.
The compiler helps once the function is generic over P: Providers, because
the providers are the only async machinery in scope. Synchronous calls like
Instant::now() slip through type checking and need eyes.
Sim Compatibility Checklist is the full
list.
Name the moments while you are there
The migration pass is the right time to add the sites simulation will use
later. They cost nothing in production and come from moonpool-buggify, which
has no dependencies.
hint!("label").awaitafter a durable write that is not yet acknowledged, before a reply is sent, between two steps of a protocol. The simulator may crash the process there.buggify!()on a rare but legal path: an early timeout, a dropped buffer, a slow branch.cpu_charge(duration)on a hot path a profile shows: a batch encode, a checksum, a fold.
Same Code in Production and Simulation explains why the sites live in shipped code and not in a test double.
Knowing the swap was safe
Because TokioProviders is the production backend, “did I change behavior?”
has a concrete answer. Moonpool ships a conformance suite under
crates/moonpool-sim/tests/conformance/.
One generic contract per provider (time, task, random, network, storage, and
the bundle) runs against TokioProviders on a real current-thread runtime. It
asserts the properties your migrated code depends on: a sleep advances the
clock by at least its duration, a timeout over a stalled future elapses, a
byte-for-byte echo survives a round trip, a write followed by sync_all reads
back identical, and a missing or existing file surfaces as NotFound or
AlreadyExists. The network contract also runs against SimProviders on the
deterministic executor, with the same body, which is how the two backends are
kept honest.
The loop is short. Make a function generic over P: Providers, swap its calls
using the table, fix the futures::io imports, and run your tokio tests. If
they pass, you lost no behavior and gained a function that simulation can
drive.
Simulation in the Browser
- Why a simulator compiles to wasm at all
- What keeps the build portable
- Does
block_onpark? - Building a wasm-able crate
- A worked example
- What stays behind
The same simulation that grinds through thousands of seeds in CI also runs in a browser tab, with no server and no install. Type a seed, watch two nodes trade frames over a chaotic network, and share a link that reproduces the exact run on anyone’s machine. That is a good demo. It is also a proof. If the engine runs inside a sandbox with no operating system, nothing in the simulation secretly depends on one.
Why a simulator compiles to wasm at all
In simulation the network and the disk are not devices. NetworkSimulation is
an in-memory state machine that schedules “deliver these bytes at logical time
T” on a queue. StorageEngine does the same for reads, writes and syncs. Time
is a counter the scheduler advances. Randomness is a seeded ChaCha8 stream.
Tasks run on the deterministic executor,
which is plain library code. Add those up and there is no syscall on the path.
The production providers are the opposite. TokioProviders is real TCP, real
files, OS randomness and getrusage, each a trip into the kernel. That single
difference is why the simulator crosses to wasm32-unknown-unknown and the
production backend does not.
What keeps the build portable
Four narrow decisions, not a rewrite.
- The explorer is a feature.
moonpool-simgatesmoonpool-explorer(libc,fork,mmap) behind the default-onexplorationfeature. Switch it off for wasm. Assertion accounting falls back to the in-process table frommoonpool-assertions, so every coverage mode still works. - No tokio runtime in the sim.
SimTaskProviderspawns onto the deterministic executor. The only tokio code left in the graph is themacrosfeature behindselect!andtokio-util’sCancellationTokenfor shutdown. Nort, nonet, nofs, notime. - Seeded randomness only.
randandrand_chachaare compiled without default features, so nothing reaches forgetrandomand the OS entropy a browser cannot answer. The sim never needed it. - Wall-clock shims.
std::time::Instant::now()andSystemTime::now()compile on wasm and then panic the moment you call them. The runner uses them only for report timings and the default base seed, throughrunner/wall_clock.rs, which returns a zero duration on wasm. Set a seed explicitly in a wasm run.moonpool-hyperapplies the same care: itsHyperTimermints its anchor without a clock on that target.
Does block_on park?
Compiling is necessary, not sufficient. A conventional runtime that runs out
of ready work parks the thread, and parking panics on
wasm32-unknown-unknown. So the real question is whether block_on ever
parks while driving a simulation.
It does not. The moonpool executor never parks. The simulation is ready-driven: every wakeup fires inline as the orchestrator steps the event queue, and no external reactor sits on a socket or a timer to wake the thread later. If nothing is runnable, the executor treats that as a deadlock and panics with the seed instead of parking.
Building a wasm-able crate
Putting a simulation in a browser is a Cargo question, not a code question. Your workload, your processes and your invariants stay as they are. The one rule is to keep the non-portable features out:
[dependencies]
moonpool-sim = { version = "0.8", default-features = false }
futures = "0.3"
default-features = false drops exploration and tokio-providers and leaves
a simulator that targets wasm. The same crate’s tests, a multi-seed chaos
binary or a CI runner depend on the same code with the features switched back
on. Cargo resolves features per build, so cargo build -p your-wasm-crate
never drags the explorer into the bundle.
A worked example
The repository ships one:
crates/moonpool-wasm-demo.
It runs one seed of two nodes trading integrity-checked ping and pong frames
over raw simulated TCP, and animates the result. The server and client are
ordinary Process and Workload code with no browser awareness:
#![allow(unused)]
fn main() {
pub fn run_seed(seed: u64) -> RunResult {
let (data, recorder) = timeline::recorder();
let _report = SimulationBuilder::new()
.processes(ProcessCount::Fixed(1), || Box::new(actors::PongServer))
.workloads(WorkloadCount::Fixed(1), |_| Box::new(actors::PingClient))
// The recorder is an Invariant: it reads the same trace timeline
// your correctness invariants read, and turns it into the picture.
.invariant(recorder)
.chaos_duration(actors::CHAOS_DURATION)
.enable_chaos([Chaos::Network(ChaosMode::Random)])
.set_iterations(1)
.set_debug_seeds(vec![seed])
.run()
.expect("simulation configuration is valid");
timeline::finish(seed, &data)
}
}
Chaos::Network(ChaosMode::Random) injects seeded latency, clogs and
connection drops, so some round trips come back slow and some never come back.
The workload asserts it: assert_sometimes!(rtt_ms >= SLOW_RTT_MS, …) and
assert_reachable!("chaos drops a request before any pong returns"). A
#[wasm_bindgen] wrapper exports run_seed_json to JavaScript as
runSeed(seed), and that is the whole browser surface.
It is running right here, compiled to wasm and served as a page asset. Press
Run, type a seed, or pick a preset. 42 is calm, 123 has some drops,
256 is chaotic, 7 is a storm. The same seed always replays the same
history.
To run it outside the book:
- Build the library for wasm:
cargo build --release --target wasm32-unknown-unknown -p moonpool-wasm-demo --lib - Generate the JavaScript glue:
wasm-bindgen --target web --out-dir crates/moonpool-wasm-demo/web/pkg target/wasm32-unknown-unknown/release/moonpool_wasm_demo.wasm - Serve
crates/moonpool-wasm-demo/web/with any static file server and press Run.
book/build-wasm-demo.sh runs those steps and stages the result in
book/src/wasm-demo/, which is where the iframe above loads from. Run it
before mdbook build. The wasm-bindgen crate version must match the CLI
exactly (0.2.129 in the Nix flake), or bindgen fails with an opaque schema
error.
Run a seed natively with cargo run -p moonpool-wasm-demo 42, then run the
same seed in the browser. You get the byte-identical history: the native
binary prints the same JSON runSeed returns. Same seed, same run, on your
laptop and in a stranger’s tab.
What stays behind
Two things do not cross over, by design. The production providers cannot run
in a browser, because a tab has no raw TCP and no filesystem. The explorer
cannot follow, because there is no fork in wasm. You lose multiverse
forking, not correctness: the in-process assertion table still tracks every
assert_always! and assert_sometimes!. The platform matrix lives in
Using Providers in Production.
To keep this path from rotting, a portability job in CI runs on every
change. It lints and tests moonpool-sim with --no-default-features, then
runs cargo check --target wasm32-unknown-unknown for moonpool-assertions,
moonpool-journal, moonpool-sim, moonpool-wasm-demo and moonpool-rpc.
A separate job builds the demo and the book together, so a broken embed fails
the pull request. The browser is not a one-time stunt. It is a gate.
Simulating the Network
- TCP, Not Packets
- What We Simulate
- Who Owns What
- A Raw TCP Process
- What We Do Not Simulate
- Same Code, Two Worlds
Part II put TCP behind NetworkProvider. In production, TokioNetworkProvider
hands the application real sockets. In simulation, the same calls hand it
provider-backed listeners and a SimTcpStream. Both implement
futures::io::AsyncRead and AsyncWrite, so the application sees one
interface in both worlds.
That seam is small on purpose. Moonpool simulates what a distributed application can observe of its network. It does not pretend to be an IP stack.
TCP, Not Packets
Many network simulators model packets: MTU fragmentation, congestion windows, selective acknowledgements, routing. That fidelity helps protocol research. It is wasted on an application whose contract starts at a TCP byte stream.
Our systems open connections, write messages, read replies and handle disconnects. The bugs that take a cluster down live at connection granularity:
- A node loses its connection mid-write and the peer reads half a message.
- A one-way partition lets one side hear heartbeats while its replies vanish.
- A process reboots while clients still hold streams to the old boot.
- A partition heals after several request deadlines have passed.
These failures need connection setup, byte delivery, partial I/O, latency and
close semantics. They do not need a simulated router. FoundationDB drew the
same line in sim2.actor.cpp: simulated connections can be delayed,
corrupted or cut, and MTU discovery stays with the kernel. Moonpool draws it
at the provider.
What We Simulate
The network engine controls:
- Connect, bind and accept. Each one is a delayed operation. It stays pending until its scheduler event fires. A listener has a backlog, and a handshake can succeed, fail or hang under chaos.
- Byte delivery. A write enters the connection’s send buffer, and the engine asks the global scheduler for ordered delivery events. Reads and writes can be partial, which catches loops that assume one poll moves one whole message.
- Latency, clogs and partitions. Per-link latency, temporary clogs and partitions decide when, or whether, bytes arrive.
- Corruption. A bit flip in flight makes checksum paths reachable. A raw protocol must bring its own integrity check.
- Connection lifecycle. A graceful close delivers buffered bytes before EOF. An abrupt close shows as an I/O error or as a silent peer.
Every decision comes from the seeded RNG. A connection that hangs on seed 73
hangs the same way on every replay of seed 73. The fault families are listed
in Network Faults: Clog,
Partition, BitFlip, RandomClose, ConnectFailure, ClockDrift,
BuggifiedDelay, PairLatency and BlackHole, each one maskable per seed
with network_fault_mask.
Who Owns What
The provider is a thin adapter over NetworkSimulation. That engine owns
listeners, connection pairs, backlogs, topology, partition state, faults,
delayed-operation results and every network waker. It owns no clock and no
private queue.
SimWorld runs one Scheduler<Event> for timers, network events, storage
events and process lifecycle. A network transition returns ordered actions,
such as “schedule a delivery” or “cancel this operation”. The coordinator
applies them, releases its lock, and only then wakes tasks. A waker that
polls network state again never deadlocks, because no wake happens under the
lock.
Endpoints are owned. A bind holds its address until the listener drops or
its process dies. A second bind of a live address fails with AddrInUse.
A connect to an address nobody listens on fails with ConnectionRefused.
Startup, shutdown and misconfiguration therefore behave as they do against
the kernel. Port zero is ephemeral and never collides.
Delayed bind and connect futures are cancellation-safe. An accept reserves
one backlogged connection while its latency elapses. Dropping the future
returns the reservation to the front of the backlog. Shutdown fails every
delayed operation and drains the waiters instead of parking them.
A Raw TCP Process
The server below is ordinary process code. It binds its assigned address,
reads one fixed-size request and echoes it. read_exact matters: the network
may split one delivery into several reads.
use futures::io::{AsyncReadExt, AsyncWriteExt};
use moonpool_sim::{NetworkProvider, TcpListenerTrait};
let listener = ctx.network().bind(ctx.my_ip()).await?;
let (mut stream, peer) = listener.accept().await?;
let mut frame = [0_u8; 16];
stream.read_exact(&mut frame).await?;
tracing::info!(%peer, "request_received");
stream.write_all(&frame).await?;
The client uses the same boundary:
let mut stream = ctx.network().connect(&server_ip).await?;
stream.write_all(&request_frame).await?;
stream.read_exact(&mut response_frame).await?;
No simulation-only socket API appears on either side. Generic code takes a
P: Providers or an N: NetworkProvider and runs over TokioProviders in
production. Inside a Process or a Workload, SimContext supplies the
simulated implementation.
Your protocol still owns framing, checksums, retries and deadlines. That is
the point: the simulator exercises the code you ship, including its behaviour
when a frame is truncated or a retry is ambiguous. For hyper, axum and tonic,
moonpool-hyper adapts the same
provider streams. For a typed request/reply layer written against these
traits, see Typed RPC with moonpool-rpc.
What We Do Not Simulate
- Packet routing between hosts
- MTU and IP fragmentation
- Congestion windows and slow start
- DNS resolution (hostnames reach the simulation only through a
moonpool_core::Resolverthe application supplies) - TLS handshakes inside the network engine (TLS runs above it, in
moonpool-rpc’s session upgrade, and thesim-rpc-tlscampaign simulates it) - Nagle’s algorithm and delayed ACKs: a simulated write is delivered after its scheduled latency and is never held back for an earlier segment’s acknowledgement
These need integration and production testing. The simulation spends its effort where seeded replay pays most: application-visible timing, failure and byte-stream behaviour.
Same Code, Two Worlds
TokioNetworkProvider sets TCP_NODELAY on every stream it connects or
accepts, as FoundationDB’s Net2 does. Request/reply protocols write a small
frame and wait for its answer. With Nagle on, that frame can wait for a
delayed ACK, which adds latency the simulator never shows. With it off,
production and simulation agree on this point. The setting is best-effort: a
platform that refuses it leaves the connection as it is.
Application Code
│
▼
┌─────────────┐
│ Providers │ ◄── trait bundle
├─────────────┤
│ NetworkProv. │ ◄── connect(), bind(), accept()
└──────┬──────┘
│
┌────┴─────┐
│ │
▼ ▼
Real TCP SimWorld
(tokio) (Scheduler<Event>)
│
▼
NetworkSimulation
There is no #[cfg(test)] branch and no socket mock in the application. The
parser, the request handling, the retry policy and the timeouts are the
shipped code, run against a hostile deterministic network. Production swaps
the provider and hands those same functions real sockets. That rule has a
chapter of its own:
Same Code in Production and Simulation.
Typed RPC with moonpool-rpc
- One Driver, Many Endpoints
- Every Failure Says What It Proves
- Choosing a Delivery Mode
- Reply Streams
- Peers, Liveness and the Failure Monitor
- Bootstrap Names
- Interfaces as Data
- Balancing Across Alternatives
- Shutting Down
- In a Simulation
Raw TCP gives us bytes. Most distributed systems want one level up: “call
this method on that process and tell me what happened”. FoundationDB built
that layer in fdbrpc, and a decade of simulation taught it which questions
an RPC layer must answer honestly. Did the server run my request? Can I
retry it? What happens to a reference after the process behind it
restarts? moonpool-rpc answers those questions. It is written once
against the provider traits, so the same runtime serves real sockets through
TokioProviders and the simulator through SimProviders. It never depends
on moonpool-sim.
One Driver, Many Endpoints
A server does not open a port per service. It runs one RpcDriver and
registers endpoints on it at runtime:
use moonpool_rpc::{AccessClass, RpcConfig, RpcDriver};
let (driver, rpc) = RpcDriver::listen(providers, "10.0.1.1:4500", RpcConfig::default()).await?;
// The driver owns every socket, registration and pending call. Poll it next
// to the application. Inside a simulated process, a crash drops it.
let (echo_ref, mut requests) = rpc.register::<Echo>(AccessClass::Public)?;
rpc is an RpcHandle: a weak, cloneable handle. echo_ref is a
ServiceRef<Echo>, plain routing data we can give to anyone. It is a
protobuf message, so it travels inside our own messages or as bytes
(to_bytes, from_bytes), and decoding it needs no runtime. It names three
things. The resolved address of the process. The incarnation, 128
random bits drawn from the RandomProvider when the runtime started. And the
endpoint token, a registry slot plus a generation that advances every
time the slot is reused.
A method is a marker type with explicit identifiers. Nothing is derived from a Rust name:
struct Echo;
impl RpcMethod for Echo {
type Request = Probe; // any #[derive(prost::Message)] struct
type Reply = Echoed;
const METHOD: MethodId = MethodId::new(0x0100);
const SCHEMA: SchemaVersion = SchemaVersion::new(1);
const NAME: &'static str = "echo";
}
Serving is pull-style. requests.recv().await yields an IncomingRequest
with the decoded request and a one-shot reply: ReplyHandle. Dropping the
RequestStream destroys the endpoint. A ReplyHandle ends one of three
ways: send answers, never_reply() says on purpose that no answer comes,
and dropping it tells the caller the promise was broken.
The caller binds a reference to its own runtime and calls:
let client = echo_ref.bind(&rpc);
let echoed = client.try_get_reply(&Probe { text: "hi".into() }).await?;
Every Failure Says What It Proves
try_get_reply is one attempt. It executes zero or one times and is
never retransmitted behind our back. When it fails, the RpcError carries a
reason (ErrorReason) and what that reason proves about execution
(Execution):
| Execution | Meaning | Examples |
|---|---|---|
NotAdmitted | No handler ever saw this attempt | endpoint not found, stale incarnation, overloaded, connect failed, still queued at the deadline |
MaybeExecuted | It may have run | disconnect after the frame left, broken promise, timeout after transmission |
Executed | It ran, but the reply was unusable | reply too large, reply that does not decode |
The runtime earns those claims. A request waits for the peer’s handshake
before it is written, so a session refused at the handshake fails its calls
as NotAdmitted. A caller that gives up withdraws a request that is still
queued, so its timeout can honestly say NotAdmitted. The delivery campaign
found the one case where it could not: an abandoned call whose frame was
queued behind the handshake went out anyway, and the server ran a request
the caller had been told was never admitted.
Choosing a Delivery Mode
One attempt is the default because it is the only mode that can promise “at most once”. FoundationDB’s four request styles are all here, and each one says what it risks:
| Call | Delivery | What a result means |
|---|---|---|
send(&req) | one-way, zero or one executions | Ok means queued, nothing more |
try_get_reply(&req), try_get_reply_within(&req, timeout), attempt(&req) | one attempt, never retransmitted | a reply, or an error that says what it proves |
get_reply(&req) | kept in memory and sent again on every new connection | may execute more than once; the first outcome wins |
get_reply_unless_failed_for(&req, sustained, slope) | reliable, bounded by observed failure | PeerFailed keeps the ambiguity of every copy sent |
Reliable delivery is about connections, not deadlines. A copy goes out again only when the connection that carried the previous one ends. A handler that ran but whose reply died with the connection runs again. The retention is memory only and lives as long as the caller. A reply, a dropped future, a terminal endpoint failure or shutdown releases it.
Here we differ from FoundationDB on purpose. When a reliable call learns that
its endpoint is gone (destroyed, or the process restarted as a new
incarnation), FDB stops retransmitting and waits forever. We end the call at
once with EndpointNotFound or StaleIncarnation. A reliable call never
reaches a fresh incarnation behind the caller’s back. Learning the new
reference is the application’s job.
attempt(&req) returns a ReplyAttempt, one attempt as a future. Keep it
after the caller moved on (the loser of a hedged pair) and it still yields
the late outcome. Drop it and a late reply is counted in late_replies and
discarded. cancel() reports what is known now: NotAdmitted if it never
left, or possibly executed.
Reply Streams
Some answers do not fit one reply: a range scan, a changefeed, a log tail. A
method opts in with const STREAMING: bool = true. The caller opens the
stream and the handler turns its reply handle into a producer:
// Caller: one registration attempt, then a futures::Stream of items.
let mut rows = scan_ref.bind(&rpc).get_reply_stream(&ScanRange { from, to })?;
while let Some(row) = rows.next().await {
let row = row?; // items in order, then the end, or exactly one Err
}
// Server: the ReplyHandle becomes a StreamProducer.
let IncomingRequest { request, reply } = requests.recv().await.expect("open");
let producer = reply.into_stream().expect("a streaming method");
for row in store.range(request.from, request.to) {
producer.send(&row).await?; // waits while the caller's window is full
}
let _ = producer.finish(); // or producer.fail(code), or drop it: a broken promise
Pacing is credit counted in consumed bytes. The caller announces a window
when it opens the stream (StreamPolicy::window_bytes, 1 MiB by default, or
get_reply_stream_with_window). Every item counts its whole frame. The
caller acknowledges an item only when its application takes it, never
when the bytes were merely read off the socket. The streams campaign proved
the difference the blunt way: a variant that acknowledged on read broke the
window bound more than 100,000 times in 40 seeds.
Four rules keep a stream honest. An item larger than the window fails its
send with SendError::TooLarge and the stream stays open. A gap or repeat
in the sequence numbers ends the stream with StreamProtocol. There is one
terminal outcome, delivered after every item that arrived, and it needs no
credit. Dropping a ReplyStream cancels it: the producer’s next send fails
with SendError::Cancelled, and nothing already done is rolled back. There
is no resumption: carrying on is a new stream the application opens.
Peers, Liveness and the Failure Monitor
Every remote address has one selected connection, dialed on demand. When
it ends, the next dial waits out a jittered backoff that grows while
connections keep failing, FoundationDB’s connectionKeeper. While a
connection lives, the dialing side pings it. Nothing within the ping timeout
means the connection is dead. That is how a black-holed peer, which TCP alone
never reports, becomes an ordinary disconnect. A served session that falls
silent while replies are still owed is probed too, because a consumer that
gave up during a partition once left its producer blocked on credit forever.
Two listening runtimes that call each other share one connection.
InboundSharing::SameIp (the default) adopts an accepted session only when
its socket really comes from the IP its Hello claims. All of this timing
is one plain struct, PeerPolicy, inside RpcConfig, so the simulation
pushes every field to an extreme on some seeds.
RpcHandle::failure_monitor() answers “is this worth calling?”. It keeps
three facts apart. An address is failed only after connections to it
kept failing for failure_detection_delay. A disconnect is counted the
moment a connection ends. A permanent endpoint failure (destroyed
endpoint, stale incarnation) is learned from the server’s own rejection and
remembered, so the next call to that reference fails without a round trip.
Every on_* wait (on_disconnect, on_failed, on_available,
on_failed_for) registers before it checks, on a versioned latch, so a
change between the check and the wait still wakes it.
Bootstrap Names
Dynamic references carry resolved addresses and die with their incarnation.
Bootstrapping needs the opposite: a fixed service at a name that survives
restarts. register_well_known::<M>(WellKnownId::new(1), ..) registers one.
Its admission ignores the incarnation, so a WellKnownRef keeps working
across server restarts. A BootstrapClient resolves the host:port through
a moonpool_core::Resolver, caches the answer for a TTL, and
retry_get_reply retries under an explicit RetryPolicy: always after a
failure proven NotAdmitted, after an ambiguous one only if
retry_ambiguous says so, and never after a contract error.
Interfaces as Data
A service with several methods is one endpoint group: one registry slot, one incarnation, several methods told apart by their explicit ids.
struct Kv;
impl RpcInterface for Kv {
const INTERFACE: InterfaceId = InterfaceId::new(0x6b76_0001);
const VERSION: SchemaVersion = SchemaVersion::new(1);
const NAME: &'static str = "kv";
}
impl InterfaceMethod<Kv> for Get {}
impl InterfaceMethod<Kv> for Put {}
let group = rpc.register_group::<Kv>(AccessClass::Public)?;
let mut gets = group.serve::<Get>()?; // a RequestStream<Get>
let mut puts = group.serve::<Put>()?;
let kv_ref: InterfaceRef<Kv> = group.interface_ref();
kv_ref.method::<Put>() gives a ServiceRef<Put> with the same address,
incarnation and token. A method the group does not serve is refused with
MethodNotFound before any handler runs. A reference to another interface is
refused with InterfaceMismatch before decoding. Both reference types are
protobuf messages with fixed tags, pinned by golden fixtures, so they are
ordinary data: a field of a request, a reply, a file on disk. A decoded
reference is checked before use, and one that names another method or
interface fails with InvalidReference before anything leaves.
Restarts are learned, never refreshed. When a process restarts at the
same ip:port, every reference to its previous incarnation is refused with
StaleIncarnation and never dispatched to the new boot, not even by a
reliable call. The new boot publishes new references and callers learn them
through the application: a well-known directory, a recruitment reply. That is
FoundationDB’s model too. A fresh endpoint says nothing about whether the
durable state behind it is the one we were talking to.
A ReplyHandle is bound to the session its request arrived on and has no
byte form, so it cannot be forwarded. A callback a third party may call is an
ordinary registered endpoint whose ServiceRef travels in the request.
Balancing Across Alternatives
A read that three replicas can serve should go to the fastest one, skip the
one that just crashed, and maybe try a second one when the first is slow.
moonpool_rpc::balance keeps FoundationDB’s loadBalance mechanics and
makes the permissions explicit, because sending one request twice is only
safe if the application says so:
let model = QueueModel::new(ModelConfig::default())?;
let balanced = BalancedClient::new(&rpc, set, model.clone(), BalanceConfig {
locality: Locality::new("m1", "dc1"),
..BalanceConfig::default()
})?;
spawn(model.collect_lagging()); // late losers still update the model
// An idempotent read: retry after an ambiguous attempt, hedge a slow one.
let policy = BalancePolicy {
retry: Retry::AfterAmbiguous,
duplicates: Duplicates::hedged(),
..BalancePolicy::default()
};
let read = balanced.call(Get { key }, &policy).await?;
The input is an AlternativeSet<M>: typed references to specific
incarnations, each with a Locality, under a SetVersion the application
chooses. The balancer never refreshes a reference. The application installs
a newer set with replace, and a call whose every alternative is gone fails
with StaleAlternatives. Two permissions are independent and off by default:
Retry::AfterAmbiguous and Duplicates::Permitted. Failing over after an
attempt proven NotAdmitted needs no permission. The balanced_mutation
example shows the difference on a deposit whose reply is lost: at most once
reports MaybeExecuted and stops, a blind retry deposits twice, a deposit
keyed by id applies once.
Shutting Down
Dropping the driver is the abrupt shutdown. RpcHandle::shutdown(grace) is
the graceful one: admission closes (peers get ServerShuttingDown, local
calls Shutdown), admitted work may finish within grace of provider time,
and the rest ends with the execution knowledge it has. The returned
ShutdownReport says whether the work drained. RpcHandle::stats() is a
snapshot of every counter and gauge, and observability::RpcMetrics exports
it as a MetricsSource with closed label sets, so a million peers add no
series.
In a Simulation
Inside a Process, the driver is polled next to the handler with
moonpool_sim::select!, so a crash drops both:
let (driver, rpc) = RpcDriver::listen(ctx.providers().clone(), &address, config).await?;
let (echo_ref, mut requests) = rpc.register::<Echo>(AccessClass::Public)?;
ctx.state().publish("echo_ref", echo_ref); // callers learn it from the application
moonpool_sim::select! {
error = driver.run() => Err(invalid_state(format!("rpc driver: {error}"))),
() = serve(&mut requests) => Ok(()),
}
The runtime draws its incarnation, jitter and backoff from the
RandomProvider, and every timeout from the TimeProvider, so a seed replays
the same session history every time. What the runtime promises, and how the
moonpool-rpc-sim campaigns check it, is the next chapter:
What moonpool-rpc Promises. Admission budgets,
security and the derive macro follow in
Admission, Security and Derive.
What moonpool-rpc Promises
The previous chapter walked through the runtime feature by feature. This one
is the contract in one place: what moonpool-rpc promises, what it does not,
and how we know. Every promise is backed by tests on real TCP and by
deterministic campaigns in moonpool-rpc-sim whose oracles never ask the
transport what it thinks happened. The full evidence record (every behaviour
row, the seeds, the mutation checks and the measurements) lives in the
repository at docs/plans/moonpool-rpc/qualification.md.
The Promises
Every failure says what it proves. An RpcError carries a reason and one
of NotAdmitted, MaybeExecuted or Executed. NotAdmitted means no
handler saw that attempt, ever. A refused retransmission of a reliable call
says MaybeExecuted, because an earlier copy may have run.
One attempt is one attempt. try_get_reply, attempt and send never
retransmit: at most one execution per call. get_reply sends again on every
new connection, so it may execute more than once, and the campaigns require
that duplicate to happen.
A reference names one incarnation, forever. A process restarted at the
same ip:port refuses its predecessor’s references with StaleIncarnation,
before any handler runs, whatever carried the reference: a direct call, a
reliable call retained across the restart, a stream, a balanced set, a stored
blob or a callback forwarded through a third process. Learning the new
incarnation is an explicit application step.
Streams are ordered, bounded and end exactly once. Items arrive in order without gaps or repeats. A producer never runs further ahead of what the consumer’s application took than its window. Every item that arrived is observable before the one terminal outcome. An abandoned stream releases its producer.
Balancing obeys its permissions. A balanced call runs at most as often as its retry and duplicate permissions allow, only on incarnations of the set it was given. Every attempt reserves and releases the queue model exactly once, late losers included.
Nobody unauthorized runs. Private endpoints fail closed. A credential is verified before dispatch on every route, local calls included, and refused for what is actually wrong with it. Key sets rotate by replacement. Validity time comes from an injected UTC clock, never from the scheduling clock.
Versions interoperate or refuse observably. Adjacent protocol versions talk. An unsupported one is refused at the handshake, never admitted.
Shutdown is honest. RpcHandle::shutdown(grace) closes admission, drains
within the grace on provider time, and ends what is left with the execution
knowledge it has.
Nothing outlives its owner. Dropping a driver releases its tasks,
connections and state. Every byte, window and owed reply is held by an owner
and returned exactly once. ResourceProbe::is_at_baseline checks all of it
after the runtime is gone.
Service comes back. After faults stop, the qualification campaign must regain every service within a declared bound of simulated time: 30 s for fresh publications, calls, a new stream, a balanced call, a credentialed call, the legacy peer, recruitment and callbacks.
What It Does Not Promise
These are deliberate:
- No exactly-once delivery, no durable queue, no duplicate suppression. Ambiguity is reported, never hidden. An operation that must not run twice needs an application key.
- No stream resumption. A broken stream ends. A new one is the application’s decision.
- No transparent incarnation or endpoint refresh. A stale reference fails. Learning the new one is explicit.
- No FoundationDB wire compatibility. The frame, envelope and codec are this crate’s own.
- No confidentiality or peer authentication in plaintext mode.
SecurityConfig::trusted_network()claims nothing. A credential goes over plaintext only where each side opts in (send_credentials_over_plaintextandaccept_credentials_over_plaintext); otherwise it is withheld (CredentialWithheld, never sent). - No multi-tenant isolation. Budgets bound resources per connection and runtime. They are not a tenant scheduler.
- No platform beyond Moonpool’s own. Linux and macOS on Tokio
(current-thread and multi-thread). On
wasm32-unknown-unknown, the protocol and the simulation build without the native TLS and JWT adapters. - No performance envelope. The measured numbers are calibration points on recorded hardware, not service-level objectives.
How We Know: Seven Campaigns
Each campaign in moonpool-rpc-sim is a SimulationBuilder with process
groups, a surviving workload, swarm network chaos (Chaos::Network(Swarm)
plus Chaos::BuggifyKnobs, which pushes every PeerPolicy knob to an
extreme on some seeds) and a scripted FaultInjector. The oracle is always
outside the transport: handlers write a ledger the moment user code runs,
and the workload judges its own outcomes against it at the end.
| Campaign | What it drives | What the ledger must show |
|---|---|---|
rpc-foundations | a server, a relay that calls the server, raw malformed sessions, forged references, bit flips and a corrupting wire | at most one receipt per request id, one for every reply, none for NotAdmitted |
rpc-delivery | every delivery mode, lost replies, broken promises, crashes mid-handler, simultaneous dials, bootstrap names | a single attempt never shows two executions; reliable delivery must sometimes show two |
rpc-interfaces | three participants restarted at their own address, directories, recruitment, forwarding to a third process, an impostor interface | every execution names its own boot and instance; a stale refusal names a boot that really ended |
rpc-balance | three servers with a character per boot, every permission, hedges, cancels, outages of every alternative | no job runs more often than its permissions allow; every attempt reserved and released once |
rpc-streams | eager, slow, abandoned and stalled consumers, saturation, producer crashes and graceful shutdowns | each consumed item is the produced one at its place; credit is earned only when an item is taken |
rpc-security | real JWTs against a rotating key set at a scripted UTC, a version 1 peer, every credential kind | no unauthorized execution, ever; every refusal names what is wrong |
rpc-qualification | all of the above at once, five processes, three client lanes, a recovery phase and resource baselines | the other campaigns’ ledgers, plus service back within the bound and every runtime at baseline |
Mutation checks keep the oracles honest. Each mutation was applied to the runtime alone and the bounded qualification suite run on seeds 1 to 30:
| Mutation | Seeds red |
|---|---|
| Incarnation check disabled at admission | 26 / 30 |
| Single attempts retained and retransmitted | 6 / 30 |
| Stream items acknowledged on arrival instead of on take | 21 / 30 |
| Queue-model reservation not released on drop | 7 / 30 |
| Credential check skipped | 30 / 30 |
Only the foundations campaign draws the simulator’s bit flips. Corruption is
one explicit experiment, whose contract is that a checksum catches it and
closes the session. Every other campaign runs
NetworkFaultMask::all().without(NetworkFault::BitFlip) on partitions,
clogs, random closes, connect failures, black holes, latency and clock drift,
so none of them mistakes corruption for message loss on healthy TCP.
To run them:
cargo xtask sim run rpc-qualification # coverage-guided, every seed twice under the RNG canary
cargo xtask sim run rpc-streams # or any other campaign name
cargo nextest run -p moonpool-rpc-sim --test qualification
cargo run --release -p moonpool-rpc-sim --example rpc_soak -- --smoke # real TCP, both Tokio flavors
The campaigns found real bugs on their first runs. The balance campaign found a lockout: a client whose alternatives all looked failed kept failing every call after the servers recovered, because nothing ever dialed them again. The streams campaign found a leak: a consumer that gave up during a partition left its producer waiting on credit forever. The interfaces campaign found a simulator bug: an in-place restart could poll the new boot before the executor had dropped the old one. A seed’s record (its history, the ledger digest and the captured trace) must be identical when the seed runs again, after other seeds ran in the same process, and with every poll slowed on the host, so no decision may come from wall-clock time.
Reading a Runtime in Production
RpcHandle::stats() returns an RpcStats snapshot. RpcMetrics exports it.
A few fields to watch:
overload_refusalsclimbing withinflight_requestsat its budget: callers are pushed back (Overloaded,NotAdmitted) instead of queued without bound. Retrying them is safe. They never ran.retained_callsandpending_callsthat never fall: callers holding reliable calls to a peer that is gone.get_reply_unless_failed_forbounds that wait by sustained failure.streams_producingwithproducer_window_reservedstuck: consumers that stopped reading. The inbound idle timeout and its ping probe release them.injected_*counters: the runtime’s own buggify points (see the next chapter). Always zero outside a simulation.ResourceProbe::outstanding()after a shutdown: every gauge an owner still holds. All zero means the runtime left nothing behind.
ShutdownReport says whether a graceful shutdown drained, how many calls it
ended and how many replies it abandoned at the deadline.
Admission, Security and Derive
- What Admission Checks, and When
- Budgets That Push Back
- Buggify Points Inside the Runtime
- Who May Call What
- Protocol Versions and Rolling Upgrades
- Generated Interfaces
Typed RPC with moonpool-rpc covered what a call means. This
chapter covers what happens to a request before a handler sees it: the
admission checks, the budgets that push back, the buggify points the runtime
carries inside those paths, who may call what, protocol versions, and the
derive macro that generates an interface from a trait.
What Admission Checks, and When
Before one byte of the body reaches the codec, the server checks, in order:
the incarnation, the token (slot and generation), the interface, the method,
the schema version, the codec, the streaming flag, and then who is
calling. A restarted process at the same ip:port refuses its
predecessor’s references with StaleIncarnation. A reused slot never answers
an old token. A caller compiled against schema 2 gets SchemaMismatch
instead of garbage. Local calls take the same path, with the same bytes,
limits and security check, so a caller cannot tell a local endpoint from a
remote one except by latency.
Budgets That Push Back
Every queue the runtime owns has a budget: per endpoint, per connection and
per runtime (ResourceLimits and StreamPolicy inside RpcConfig). A
budget is enforced where work would enter, and a refusal there is
Overloaded with NotAdmitted: nothing was queued, sent or handed to a
handler. Nothing already admitted is ever dropped to make room.
| Budget | Scope | Default |
|---|---|---|
RpcConfig::endpoint_queue_capacity, ResourceLimits::endpoint_queue_bytes | endpoint | 256 requests, 16 MiB |
ResourceLimits::max_inflight_per_connection, max_inflight_requests | connection, runtime | 1024, 16384 |
RpcConfig::max_queued_requests, ResourceLimits::max_queued_bytes_per_connection | connection | 1024, 16 MiB |
ResourceLimits::max_retained_bytes | runtime | 64 MiB of reliable request bodies |
StreamPolicy::window_bytes, max_buffered_bytes | stream, consuming runtime | 1 MiB, 256 MiB |
StreamPolicy::max_producer_bytes_per_connection, max_producer_bytes | connection, producing runtime | 64 MiB, 1 GiB |
RpcConfig::max_frame_bytes | frame | 1 MiB |
Replies are the interesting one. In the first package replies shared the control queue, so a caller that pipelined requests to a server whose writer was stuck could fill that queue and get the whole session closed. Now each admitted request reserves the room of its reply, and beyond the in-flight budget new requests are refused before admission. A slow writer pushes back instead of disconnecting.
Progress under saturation comes from ordering, not from a scheduler. The
writer sends control frames first (handshake, pings, rejections), then the
coalesced stream acknowledgements, then one data frame per source in turn.
The socket reader never waits on an application queue, and reader and
writer both yield after max_frames_per_batch frames (64 by default,
measured with the stream_saturation example). A burst of 4096 slow calls
against the defaults admits 1024 and refuses the other 3072 as Overloaded,
with the session still open.
Per-Endpoint Admission Queues
Every endpoint takes the runtime’s queue (RpcConfig::endpoint_queue())
unless it brings its own. register_with, register_well_known_with and
ServiceGroup::serve_with take an EndpointQueue, so one runtime can serve
a latency-sensitive endpoint with a short queue next to a bulk endpoint with
a deep one:
use std::num::{NonZeroU64, NonZeroUsize};
use moonpool_rpc::EndpointQueue;
// A short queue: a heartbeat that waits is worse than one refused.
let short = EndpointQueue::new(NonZeroUsize::new(8)?, NonZeroU64::new(64 << 10)?);
let (beat_ref, mut beats) = rpc.register_with::<Heartbeat>(AccessClass::Public, short)?;
Both bounds are non-zero by construction: an endpoint that can queue nothing could never admit a request.
Buggify Points Inside the Runtime
The campaigns’ faults come from outside the runtime: network chaos and
scripted crashes. Those rarely land on the exact instant a contract is
decided. So moonpool-rpc carries its own BUGGIFY points through the
zero-dependency moonpool-buggify crate, inert outside a simulation. Each
one forces an outcome the contract already allows, never one it rules out:
| Point | What it forces | The contract it exercises |
|---|---|---|
Cut behind an at-most-once request (buggify_fault_with_prob!(0.01)) | The session is flushed and closed right after a two-way request’s frame left. | A single attempt fails MaybeExecuted, never NotAdmitted, and never runs twice. |
Cut behind a reliable request (buggify_fault_with_prob!(0.1)) | The same, after a reliable request. | The retained copy is sent again and may run more than once. |
Spurious overload at admission (buggify_fault_with_prob!(0.02)) | A request is refused Overloaded after its security check, before any queue. | Declined without effect, and a balancer fails over. |
Deferred stream acknowledgement (buggify_with_prob!(0.5)) | A taken item’s acknowledgement waits for the next item’s cumulative one. | Credit is delayed, never lost. |
Spurious failure-monitor wakeup (buggify_with_prob!(0.1)) | The change latch is bumped with nothing changed. | Every wait re-checks its condition. |
The first three use buggify_fault_with_prob!, so they stop when the chaos
window closes and a campaign’s recovery checks see a quiet runtime. Each one
is counted in RpcStats (injected_request_cuts,
injected_reliable_request_cuts, injected_overloads,
injected_ack_deferrals, injected_spurious_wakeups), and the campaigns
turn every non-zero counter into an assert_reachable!, so a sweep shows
that each point fired. See Buggify for
how activation and swarm masks reach these sites.
Who May Call What
An address is not an identity, and neither is an endpoint token. Both are
routing data anyone can copy. moonpool-rpc separates three questions.
Who is the server? Server-authenticated TLS answers it. Who is the
client? A credential attached to each request answers it, verified by the
server. May this client call this endpoint? An AccessPolicy answers
it, per request, in admission, before the body is decoded.
Every endpoint is registered Public or Private. Private endpoints fail
closed. The default SecurityConfig verifies nothing, so public endpoints
answer anyone and private ones refuse everyone:
use moonpool_rpc::security::jwt::{JwksKeys, JwtConfig, JwtVerifier}; // feature `jwt`
use moonpool_rpc::security::{Credential, SecurityConfig, SystemUtc};
let keys = JwksKeys::from_json(&jwks_document)?;
let verifier = JwtVerifier::new(keys.clone(), JwtConfig::new("issuer", "rpc"))?;
let config = RpcConfig {
security: SecurityConfig::enforced(verifier).with_clock(SystemUtc),
..RpcConfig::default()
};
// Callers attach a credential per runtime (`SecurityConfig::with_credentials`)
// or per client.
let client = service_ref.bind(&rpc).with_credentials(Credential::bearer(token));
Three starting points exist. SecurityConfig::default() verifies nothing.
SecurityConfig::enforced(verifier) verifies credentials and authorizes
with DefaultPolicy, or your own with_policy. SecurityConfig::trusted_network()
admits everything and claims nothing, FoundationDB’s plaintext default, here
a choice and never an accident. An IpAllowList (with_allow_list) refuses
connections before a byte is read, but it restricts reachability only: an
allowed address still needs a credential for a private endpoint.
A refusal is Unauthenticated(CredentialError) or PermissionDenied. The
CredentialError names what is wrong: Missing, Malformed, TooLarge,
UnsupportedAlgorithm, UnknownKey, BadSignature, WrongIssuer,
WrongAudience, Expired, NotYetValid, ClockUnavailable,
InsecureTransport or Rejected. Both refusals are NotAdmitted and
neither is terminal for the reference, because keys, tokens and policies
change. The handler sees the verified caller through
ReplyHandle::principal(). Denials are audit events (target
moonpool_rpc::audit) that name the endpoint, the peer and the reason, never
the credential, and the moonpool_rpc_requests_denied_total{reason} counter
aggregates them.
JWT and JWKS (feature jwt). JwtVerifier checks, in order: size,
header and algorithm allow list (public-key algorithms only, so the classic
HS256 confusion is refused), the key id in the current key set, the
signature, issuer and audience, then exp and nbf. JwksKeys::replace_json
swaps the whole set, as FoundationDB’s applyPublicKeySet does: a key left
out is revoked at once.
Credentials never leave on a session that cannot protect them. A request
carrying a credential goes out only on an encrypted session, or when the
client opted in with send_credentials_over_plaintext(), and never on a
protocol version 1 session. A refused send fails as NotAdmitted with
CredentialWithheld. A session this runtime accepted never carries one
either, because nothing authenticated its dialer.
TLS (feature tls). security::tls::Tls is a session upgrade over the
provider’s own streams (rustls through futures-rustls, TLS 1.3, the ring
provider), passed to RpcDriver::listen_with or client_only_with. The
client verifies the server’s chain and the name it expects (ServerNames,
by default the IP it dialed). RotatingCertificate::replace and
TlsClient::replace_roots rotate for new sessions.
Mutual TLS. A server asks for client certificates with
TlsServer::with_client_auth: ClientAuth::Required(roots) refuses a client
without a certificate that chains to roots and is valid at the injected
UTC, ClientAuth::Optional(roots) verifies one when presented, and
ClientAuth::Off (the default) asks for none. A client presents one with
TlsClient::with_certificate, and rotates it through
TlsClient::certificate(). TlsServer::replace_client_roots rotates the
trusted client authorities. The verified certificate’s first URI name (else
its first DNS name, else its first IP address) becomes the session’s
PeerContext::identity. It grants access only on a runtime that says so
with SecurityConfig::admit_authenticated_peers(): a request without a
credential then runs as Principal::new(identity) with issuer "tls", and
the access policy judges it like any other principal.
Certificates (feature tls-pki). security::tls::pki::Authority makes
Ed25519 roots and the leaves they sign (LeafSpec: names, UTC validity, use,
key seed). KeySeed::Random draws operating-system entropy;
KeySeed::Bytes derives the key from 32 bytes, so a simulation seed always
makes the same certificates. In a simulation, buggify sites in
Authority::issue sometimes issue a flawed leaf (short-lived, not valid
yet, a name dropped, the wrong use) and say so in Issued::flaw.
TLS under simulation. The sim-rpc-tls campaign runs this same TLS
code over the simulated network, with the seed’s client authentication
mode, an impostor server whose stray authority copies the real one’s name,
certificate rotation, UTC jumps past certificate lifetimes, and bit flips.
Ed25519 and X25519 keep every handshake the same size, so the determinism
canary holds; the ciphertext bytes still differ between runs.
Two clocks. TimeProvider::now is scheduling time, never Unix time, so
credential and certificate validity read a separate UtcClock: SystemUtc
in production, a scripted FixedUtc in tests and simulation. The default
NoUtc knows no time, so an expiring credential fails closed until a clock
is chosen. The TLS and JWT adapters are native-only features. The policy
itself (SecurityConfig, AccessPolicy, RequestVerifier, allow lists, key
rotation, the clocks) is always compiled, so simulations and wasm builds
exercise the same trust decisions.
Protocol Versions and Rolling Upgrades
Each runtime announces the versions it speaks (RpcConfig::protocol_versions,
1..=2 by default). A session runs at the highest version both sides
speak, and a peer with none in common is refused at the handshake
(version_rejections counts them). Version 2 changes no frame layout: it
gives the request’s metadata section its credential meaning and adds three
reply statuses. Nothing newer than the negotiated version is ever sent or
decoded. A refusal a version 1 peer cannot decode reaches it as the
version 1 status that proves the same thing: endpoint not found. A server
that verifies credentials speaks version 2 only, so an old client is refused
at the handshake instead of being let in anonymously. Golden hex fixtures
(tests/fixtures/wire-v1.txt, wire-v2.txt) pin every envelope of both
versions. To pin a build during an upgrade, narrow the range to 1..=1.
Generated Interfaces
With the derive feature, one trait generates the interface written by hand
in the previous chapter:
#[moonpool_rpc::service(id = 0x6b76_0001, version = 1)]
pub trait Kv {
#[method(id = 1, schema = 1)]
async fn get(&self, key: Key) -> Value;
#[method(id = 2, schema = 1)]
async fn put(&self, entry: Entry);
}
It generates KvInterface (the RpcInterface marker), one method marker per
method (KvGet, KvPut), KvRef (an InterfaceRef<KvInterface>),
KvClient<P>, KvRequest (one variant per method, holding the
IncomingRequest and its ReplyHandle) and KvServer. Serving a handler
that implements the trait is three calls:
let kv = KvServer::register(&rpc, AccessClass::Public)?;
directory.publish(kv.interface_ref().to_bytes()); // the application's job
kv.serve(&store).await; // pulls the next request of any method and replies
// A caller: every delivery mode, per method.
let kv = KvClient::bind(&KvRef::from_bytes(&published)?, &rpc)?;
let value = kv.get().try_get_reply(&key).await?;
The macro owns no protocol state. A server that wants to reply later, never
reply or look at the caller calls kv.next().await and matches on
KvRequest::Get(IncomingRequest { request, reply }). Ids stay explicit, and
a duplicate method id is a compile error, also when it hides behind a
constant. A hand-written interface with the same ids is byte-for-byte
interchangeable with the generated one: tests/derive.rs serves a derived
server to a manual client and a manual server to a derived client. The
derive feature is off by default, so a production tree stays free of
proc-macro dependencies unless asked for. Through the facade crate, the
features are moonpool’s rpc, rpc-derive, rpc-tls and rpc-jwt.
Multiverse Exploration
A simulation campaign runs many seeds. Each seed is one timeline. For most bugs, many seeds are enough. Some bugs are different. They need a sequence of rare events, and no single seed produces that exact sequence. Multiverse exploration is the answer. It is the capstone of moonpool, and the moonpool-explorer crate implements it.
The Core Idea
A deterministic simulation makes every execution a reproducible coordinate. When a timeline reaches an interesting state for the first time (a retry fires, a leader changes, a dungeon floor is entered), moonpool remembers how to get back there. The memory is small: the root seed plus the RNG call count of the discovery. Later, the explorer replays that prefix and continues with fresh randomness. This is a short randomized rollout from a proven starting point.
A timeline that reached an interesting state is a good place to start the search for the next one. Exploration turns executions into accumulated knowledge: interesting states are remembered, resumed and extended. Barren branches die.
The tree of explored timelines is a logical structure, not a physical one. The multiverse can hold thousands of timelines. The physical footprint is one controller process plus a small, fixed pool of short-lived workers. A state ten discoveries deep costs a recipe with ten segments, not ten live processes.
Vocabulary
The explorer’s own glossary, in crates/moonpool-explorer/src/lib.rs, uses these terms. The book uses the same ones.
| Term | Meaning |
|---|---|
| Seed | A u64 that fully determines a simulation’s randomness. |
| Timeline | One complete simulation run: a root seed plus a recipe. |
| Recipe | Replay breakpoints: a list of (rng_call_count, seed) segments. |
| Discovery | A globally new interesting state, reported by an assertion macro. |
| Journal | The discoveries one run made, each stamped with its RNG call count. |
| Frontier | The FIFO queue of jobs (recipes) that wait to be explored. |
| Exemplar | A retained recipe plus anchor that reaches one semantic state. |
| Worker | A forked process that runs exactly one timeline and exits. |
A recipe reads as a one-line timeline string:
151@8837201 -> 80@1293847 -> 42@9918273
“Run with the root seed. After 151 RNG calls, reseed to 8837201. After 80 more, reseed to 1293847. After 42 more, reseed to 9918273.” Follow those instructions and you get the same timeline, task scheduling included. The executor draws its schedule from the same counted stream the recipe replays. moonpool_sim::format_timeline and parse_timeline convert between the string and the Vec<(u64, u64)>.
Turning It On
Exploration is a builder option. It needs factory workloads, because every continuation starts a fresh test driver:
use moonpool_sim::{ExplorationConfig, SimulationBuilder};
let report = SimulationBuilder::new()
.workload_factory(|| Box::new(MazeWorkload::default()))
.enable_exploration(ExplorationConfig {
workers: 0, // in-process: deterministic, no fork
max_runs_per_seed: 2400, // timelines per root seed, a ceiling
branching_factor: 4, // children per productive run
max_frontier: 512, // queued jobs
max_recipe_len: 32, // depth in replay segments
})
.set_iterations(2)
.run()?;
This is the sim-maze-explore binary in moonpool-sim-examples. The exploration feature of moonpool-sim is on by default. It pulls in moonpool-explorer, which uses libc for fork, waitpid and mmap. Turn the feature off for a wasm build. Without it, the assertion macros still count in a heap table, and UntilCoverageStable still works.
What This Part Covers
- The Exploration Problem explains why random seeds are not enough for deep bugs, with the NES games Antithesis used as examples.
- The Frontier Controller describes the loop: recipes, discovery journals, the one-expansion rule, and how barren branches die.
- Bounded Workers covers the process model:
fork()as a job executor, single-owner novelty, crash isolation, the wall-clock ceiling, and macOS portability. - Exemplars and Continuations shows how the controller remembers semantic states and re-invests in the under-explored frontier.
- Multi-Seed Exploration shows how cumulative novelty makes extra root seeds cheap.
- Exploring a Consensus Protocol applies all of it to Paxos-style systems.
moonpool-explorer is a leaf crate with one external dependency, libc. It knows nothing about processes, networks or storage. It talks to the simulation through the discovery hook of moonpool-assertions and one function pointer that reads the RNG call count (set_rng_count_hook). The coupling is minimal on purpose: the controller works with any deterministic simulation that counts its draws.
The Exploration Problem
Thousands of random seeds find race conditions, timing bugs and broken error paths that hand-written tests never reach. One class of bugs resists them. To see why, we need to look at how probabilities compose.
The Sequential Luck Problem
Take a distributed lock service with a subtle bug. The lock can be granted to two nodes at once, but only when a failover happens inside a rollback window. The failover has a 1 in 1000 chance per step. The rollback has a 1 in 1000 chance per step. The bug needs both in one run.
With independent seeds, the chance of both events in one run is about 1 in 1,000,000. We need about a million runs for a fair chance to see it. At a few seconds per run, that is weeks of compute for one bug.
This is the Sequential Luck Problem. A bug that needs N rare events in sequence has a probability that shrinks multiplicatively. Two events at 1 in 1000 need about 10^6 trials. Three need about 10^9. The search space grows exponentially with the depth of the sequence.
Now replay and continue. We run seeds until one of them triggers the failover, about 1000 runs. At that moment we keep the replay prefix and schedule new timelines that reproduce the failover, then continue with different randomness. Each continuation has a 1 in 1000 chance to hit the rollback window. We need about 1000 continuations. Total: about 2000 timelines instead of 1,000,000.
The change is not incremental. The cost goes from multiplicative (p1 * p2) to additive (p1 + p2). For a three-event bug, about 3000 timelines replace 10^9.
The explorer’s own test scenario shows the same arithmetic. The floor ladder in crates/moonpool-explorer/src/simulations.rs has 8 floors behind a 1 in 6 gate each. Brute force reaches the top floor once in about 1.7 million runs (6^-8). The explorer climbs it within a 2000-run budget, in-process and with workers.
State Space Shapes
Not every exploration problem has the same shape. Antithesis learned this by running NES games under its platform, and each game taught a different lesson.
Breadth-Dominated: The Zelda Problem
The Legend of Zelda (1986) is an open world: 128 overworld screens, more than 230 dungeon rooms, 25 prerequisite items. The state space is wide. There are many things to discover, and they do not need to happen in a strict order. The challenge is coverage of a large surface, not one hard barrier.
Antithesis solved Zelda with SOMETIMES_EACH, which equalizes exploration across every discovered state. Visit screen 47. Good. Now visit screen 48 as often. The budget spreads across the breadth of the space, so no region is neglected.
In moonpool this is assert_sometimes_each!, which tracks coverage per identity value. A database can use it to make sure every partition range sees traffic, or every node role gets exercised.
Depth-Dominated: The Gradius Insight
Gradius (1985) is an auto-scrolling shooter. The player cannot go back. Progress is survival time. Antithesis first tracked powerups, weapons, ship position and score. Then they deleted all of it and tracked 3 bytes of game memory: single-player mode, pause state, death animation. The objective became “maximize time since power-on”.
The minimal strategy beat the whole game.
For depth-dominated problems, the platform (checkpoint and branch) matters more than domain strategy. The agent’s play was alien: it burned powerups on speed, clipped through walls, hid from bosses until they timed out. It worked because the platform kept branching from the deepest point reached.
For simulation testing this means: a minimal progress watermark (assert_sometimes_greater_than!(level, 0, "dungeon level reached")) plus aggressive branching can beat elaborate state tracking.
Barriers: The Castlevania Trap
Castlevania’s stage 6 has stompers, pillars that descend and crush the player. The engine tracked Simon’s position on a 32-pixel grid and equalized coverage across cells. It cruised through five stages. Then it stuck.
The problem was doomed states. The best exemplar for the critical cells was Simon standing under a descending stomper. No future input saves him. The explorer kept restarting from that doomed state and made no progress.
Two fixes unlocked it. A finer 16-pixel grid created safe cells between the stompers. Adding the stomper position to the state tuple told “under a high stomper” from “under a low stomper”.
The lesson: when exploration stalls, fix the input distribution (how chaos is generated) or the output interpretation (which signals guide the search). Coverage reports and sometimes-assertion hit rates tell you which one to adjust. Moonpool’s answer to doomed exemplars is in Exemplars and Continuations.
Continuous Optimization: The Metroid Economy
Metroid (1986) was the hardest. The engine reached everything accessible without missiles. Red doors need 5 missiles, and the engine kept spending missiles on enemies because that helped short-term exploration.
Naive fixes failed. Missile count in the state tuple caused state explosion. Requiring 5 or more missiles was too strict, because some areas need you to spend them.
The solution was continuous optimization: decouple the optimization objective from the exploration objective. Explore every position, but prefer states with more missiles and health. When a better-resourced path to a state appears, propagate it downstream.
This generalizes to systems. Prefer states with lower memory to find leaks, deeper queues to find overflow bugs, higher latency to find timeout bugs. In moonpool the quality keys of assert_sometimes_each! carry this signal.
Exploration as Resource Allocation
The NES examples show that exploration is a resource allocation problem, not only a randomness problem.
We have a finite compute budget. We can spend it on breadth (more root seeds) or depth (more continuations from interesting states). We can spread it evenly across known states or concentrate it on the most promising ones.
Each choice decides which bugs we find. Breadth misses deep sequential bugs. Depth misses unexplored regions. Even allocation wastes budget on doomed states. Concentrated allocation can skip productive ones.
The next three chapters describe moonpool’s policy. It is simple on purpose: a productive run produces a bounded number of children, an unproductive run produces none. We start with the frontier controller, then the bounded worker pool, then the exemplar store and continuation scheduling that keep pushing the frontier deeper.
The Frontier Controller
The heart of the explorer is a small loop in one process. It owns every exploration decision. Nothing else decides anything: assertion macros describe interesting behavior, workers execute timelines, and the controller, the Explorer type in moonpool-explorer, turns discoveries into follow-up work.
controller (Explorer)
|
frontier (FIFO queue of jobs)
|
+-----------+-----------+
| | |
worker worker worker
| | |
+-----------+-----------+
|
discovery journals
|
productive? -> expand once
barren? -> the branch dies
Jobs Are Recipes
The unit of work is minimal:
pub struct ExploreJob {
pub recipe: Recipe, // Vec<(rng_call_count, seed)>
}
A job says: replay this prefix, then keep going with the fresh randomness of the final segment’s seed. To execute a job, the runner runs one complete simulation from the start with the recipe installed as RNG breakpoints (set_rng_breakpoints). When the call count passes a breakpoint’s target, the stream reseeds and the count restarts at 1.
Every decision in a run is a counted draw on the one simulation stream: executor scheduling, select! offsets, swarm masks, fault coins, the workload’s own draws. So replay is exact, and cheap enough that no live checkpoint is needed. The network and storage profiles are drawn before the reset that starts the count, so a breakpoint can never land on them. Attrition, outage, CPU and gray-failure regimes are drawn after it, so they replay too.
The Discovery Journal
During a run, every globally new discovery goes into a per-run journal as a DiscoveryEvent: the kind, the semantic state_id, and the call_count at that moment. The call count is the coordinate where a child recipe can anchor.
root run (seed 42):
journal: [floor 2 entered @ call 812, key found @ call 1490]
expansion, anchored at the highest-priority discovery:
progress > structured state novelty > one-shot coverage
the latest call count wins inside a class
The six DiscoveryKind values fall into three classes:
| Class | Kinds | Example |
|---|---|---|
| Progress | WatermarkImprovement, FrontierAdvance, BucketQuality | a numeric watermark moves, more assert_sometimes_all! facts true at once, a bucket’s quality improves |
| Structured novelty | BucketFirst, BooleanCombination | a new assert_sometimes_each! bucket, a new partial truth combination |
| Coverage | SometimesPass | the first pass of assert_sometimes! or assert_reachable! |
This order stops a late, easy coverage hit from pulling the children away from an earlier step toward a hard goal. Every discovery still registers its own exemplar for later continuation scheduling.
The journal holds at most 256 entries (MAX_JOURNAL_ENTRIES). Repeated events for one state coalesce to the best anchor. When the journal is full, a stronger discovery replaces the weakest retained one. Early coverage noise can never hide late progress.
One Expansion Per Run
A run with at least one discovery is productive. The controller expands it exactly once: it enqueues branching_factor children, anchored at the highest-priority discovery. A run that made five discoveries is still one productive execution, not five branching events. The child seeds are derived by hashing the root seed, the base seed, the anchor, the state id, the continuation round and the child index, so the frontier is deterministic too.
A run with an empty journal found nothing the multiverse had not seen. It is not punished or scored. Its branch produces no children and dies. There is no energy accounting. The only global limits are the per-seed run budget, the frontier size and the recipe length.
ExplorationConfig {
workers: 0, // default: in-process, deterministic
max_runs_per_seed: 512, // default
branching_factor: 4, // default
max_frontier: 1024, // default
max_recipe_len: 64, // default
}
These are the ExplorationConfig::default() values. enable_exploration panics on a zero bound; workers: 0 is valid. The dungeon binary uses max_runs_per_seed: 24_000 with 4 workers, the maze binary 2400 with none.
Who Decides Novelty
Novelty detection lives in the shared assertion region of moonpool-assertions. First passes, new buckets, watermark improvements, frontier advances and partial truth combinations are guarded by atomic compare-and-swap latches. The timeline that wins the latch journals the discovery. Every other timeline, in any process, sees “already known”. There is no check-then-merge race.
The controller is the single owner of everything built on top: frontier, exemplars, statistics and bug recipes. The runner installs one hook so the journal can stamp each discovery: set_rng_count_hook(rng_call_count).
Bug Recipes
A failing exploration run (a workload error, an assert_always! violation, or a worker crash) is recorded with its job’s recipe. The controller keeps at most 4 per seed (MAX_BUG_RECIPES). The root seed is marked failed in the main report, and report.exploration.bug_recipes carries each BugRecipe { seed, recipe }. Replay reproduces the failing timeline bit for bit:
let bug = report.exploration.as_ref().and_then(|e| e.bug_recipes.first())?;
SimulationBuilder::new()
.workload_factory(|| Box::new(MyWorkload::default()))
.replay_timeline(bug.seed, bug.recipe.clone())
.run()?;
replay_timeline sets one seed, one iteration, and installs the recipe for that run.
The next chapter looks at how jobs run while the process count stays fixed.
Bounded Workers
- fork() Is an Optimization, Not a Data Structure
- The Wall-Clock Ceiling
- What Crosses the Process Boundary
- Portability
An earlier explorer was its process tree. A discovery forked the running simulation mid-poll. Children forked again at their own discoveries. The logical tree became a tree of live OS processes. It worked, and it made deep exploration impractical: depth multiplied the number of live processes.
The current design inverts the relationship:
The size of the logical exploration space is independent of the number of live OS processes.
fork() Is an Optimization, Not a Data Structure
The controller runs each job through a fixed pool of workers:
controller --fork--> worker (slot 0) -- runs ONE timeline --> _exit
--fork--> worker (slot 1) -- runs ONE timeline --> _exit
<-waitpid- read the slot's journal, decide, expand
The controller forks at a quiescent point between runs, never mid-simulation. The child inherits the prepared runner state by copy-on-write. It runs exactly one replay plus continuation, serializes its discovery journal and sancov counters into its MAP_SHARED result slot, and calls _exit. The exit code says how the run ended:
| Exit | Meaning |
|---|---|
| 0 | The run completed and passed. |
| 42 | The run completed and reported a simulation failure. |
| any other code, or a signal | The worker crashed (a panic, an abort). |
Nothing catches a panic inside a worker. The worker dies, the controller classifies the waitpid status, and the recipe is kept as a crash reproducer like any other failure. Workers never fork, never touch the frontier, and never return into controller code. Recursion is structurally impossible.
Live processes are bounded by 1 + workers at all times. A state fifty discoveries deep costs a fifty-segment recipe, not fifty processes. If fork itself fails (a resource limit), the controller runs that job in-process instead.
With workers: 0 the controller runs every job in-process: sequential, fork-free, and fully deterministic. The same inputs give the same statistics and the same bug recipes every time, and the integration test in_process_exploration_is_deterministic checks exactly that. Use this mode in cargo nextest tests and on non-unix targets, where it is the only mode. With workers > 0, every timeline is still reproducible from its recipe, but the search order depends on which worker finishes first.
The Wall-Clock Ceiling
A worker stuck outside the simulation (a tight loop in user code, a blocking syscall) is invisible to the in-simulation stall guards. The controller stamps each fork with the wall clock. A worker still running after DEFAULT_WORKER_TIMEOUT (5 minutes) is killed with SIGKILL, reaped, and recorded as a crash with its recipe printed. Explorer::set_worker_timeout changes the ceiling. A timeline normally takes milliseconds to seconds, so the default is generous on purpose.
The controller polls its own children by pid with WNOHANG, never waitpid(-1), so a harness that embeds the explorer keeps its own children. It spins for 2048 polls, then sleeps between 10 microseconds and 1 millisecond, so short timelines pay no sleep latency. Dropping an Explorer kills and reaps every live worker, so a panic in the harness cannot orphan a worker that holds the shared regions.
What Crosses the Process Boundary
Exactly three things are shared between controller and workers:
- The assertion region: the 2048-slot assertion table and the 256-bucket
assert_sometimes_each!table frommoonpool-assertions, mappedMAP_SHARED | MAP_ANONYMOUSbyinit_assertions. Pass and fail counts accumulate across processes. The discovery latches make novelty a race-free, exactly-once decision. - Result slots: one per worker. A slot holds a
u32length and up to 256 raw events of(call_count, state_id, kind). The controller zeroes the slot before the fork. The worker writes the entries first and the length last, so an interrupted worker leaves an empty journal. Its recipe is still kept as a crash reproducer. - Sancov buffers: LLVM
inline-8bit-counterscopied into a per-worker slot and merged into the controller-owned history map afterwaitpid. One owner, no concurrent merges. See Running Simulation Binaries with xtask for how the counters get into the binary.
Everything else (frontier, exemplars, statistics, bug recipes) lives in ordinary controller memory with one owner.
Under exploration the runner scrapes application metrics from root timelines only. Explored timelines report back through the shared assertion table, which carries no metric values.
Portability
The explorer uses only portable POSIX primitives: fork, waitpid with EINTR retry, kill, and mmap(MAP_SHARED | MAP_ANONYMOUS). There is no clone3, no pidfd, no /proc, no cgroups. The same code runs on macOS and Linux, and CI runs the sim-frontier-explore binary on both. Fork safety holds because the simulation stack is single-threaded by design: the deterministic executor runs on one OS thread, and the controller forks only between runs when no lock is held.
Exemplars and Continuations
- Semantic States
- A Few Exemplars Per State
- Continuations From the Under-Explored Frontier
- The Dungeon Walkthrough
Expansion alone is momentum. A productive run spawns a few children. If a child is productive, the wave carries forward. But waves die. Four children from the “key found on floor 5” moment can all get eaten by monsters. With plain expansion, that hard-won state is lost. This chapter covers the machinery that remembers progress and keeps re-investing in it.
Semantic States
Every discovery carries a state_id. For a slot assertion it is the hash of the message. For a partial assert_sometimes_all! combination it is the site plus the truth combination. For assert_sometimes_each! it is the bucket hash: message plus identity keys. This is the Castlevania lesson from The Exploration Problem, applied through the macros you already write. The workload’s assertions project the huge concrete state space into a small set of semantic buckets.
assert_sometimes_each!("descended",
[("to_floor", floor)], // identity: one state per floor
[("health", hp_bucket)]); // quality: a watermark per state
The controller tracks up to 512 distinct states per seed (MAX_TRACKED_STATES). For each state it records whether any discovery there was progress (a watermark, frontier or quality improvement) rather than plain coverage, and how many continuation batches it has received.
A Few Exemplars Per State
For each state the controller keeps up to three exemplars (MAX_EXEMPLARS_PER_STATE): recipes that reach it, anchored at the discovery’s RNG count. Three, not one, because of the doomed-state trap. “Floor 4 at full health” and “floor 4 about to die” hit the same bucket with very different futures. If the only retained exemplar is doomed, every continuation from that state is wasted.
When a state’s list is full, a new exemplar evicts the oldest through a rotating cursor. A known state is rediscovered only through a quality or watermark improvement, so recency correlates with a better starting point. The dying exemplar rotates out when a healthier path to the same floor appears. This is the Metroid lesson (prefer better-resourced paths to the same place) as a three-slot ring buffer instead of an optimization framework.
Continuations From the Under-Explored Frontier
When the frontier drains and run budget remains, the controller schedules a fresh batch of continuations from the most promising known state:
pick the state that minimizes: visits / (depth + 1)
ties: progress states first, then deeper recipes, then registration order
rotate through that state's eligible exemplars
enqueue branching_factor children with fresh derived seeds
The depth weighting is the whole scoring system. A state whose exemplars sit N replay segments deep gets about N+1 times the continuation budget of a shallow one, so effort concentrates on the frontier instead of spreading uniformly. The visit counter still guarantees that every state gets revisited. An exemplar whose recipe already has max_recipe_len segments is not eligible. No energy, no reinforcement learning, no corpus manager.
The loop ends when the per-seed budget is spent or when no state has an eligible exemplar left.
The Dungeon Walkthrough
The dungeon workload in moonpool-sim-examples is the acceptance benchmark for this design. Ten levels by default (DEFAULT_TARGET_LEVEL). On each level below the last, the key tile grants a key with 2% probability (KEY_FIND_P). Monsters scale with depth. A treasure waits on the last level. The brute-force chance to reach it is tiny: nine 2% gates in sequence. Reaching it requires accumulated progress.
root run: wanders level 1, discovers "on key tile" floor 1 @ call 812
+- continuations replay to the key tile and re-roll the 2% gate
+- "key found" -> "stairs with key" -> "descended" to_floor 2
+- each new level adds states, exemplars, deeper anchors
+- ... level 10, "treasure found": a bug recipe
The binary sim-dungeon-explore runs it with workers: 4 and max_runs_per_seed: 24_000 over 3 seeds. At most five processes are live at any time. The run passes when the treasure is found or when the "dungeon level reached" watermark reaches at least level 5. A stall at level 4 or below is a regression in the explorer’s guidance, not seed variance.
The workload contributes structure the explorer relies on but never sees:
- correlated action sequences (70% chance to repeat the previous move,
REPEAT_ACTION_P), so continuations are rollouts rather than isolated decisions; - a fixed number of RNG draws per step, so replay coordinates stay meaningful;
- semantic assertions that name the states worth remembering:
"on key tile"and"stairs with key"bucketed by floor with a health quality,"descended"bucketed byto_floor, and the watermarks"dungeon level reached"and"health remaining".
Multi-Seed Exploration
- Two Kinds of State
- A Barren Seed Costs One Run
- What Cumulative Novelty Buys
- Interplay With UntilCoverageStable
- The Complete Picture
Exploration runs per root seed. A campaign with set_iterations(3) explores three multiverses, one after the other. The naive design would reset everything between seeds and spend the full max_runs_per_seed budget three times. Moonpool does not. Novelty is cumulative, so later seeds only pay for what earlier seeds did not find.
Two Kinds of State
The controller keeps two kinds of state, and resets only one of them when a new seed starts.
| State | Scope | Reset by begin_seed |
|---|---|---|
| Frontier, exemplars, tracked states, statistics, bug recipes, run counter | One seed | Yes |
| Discovery latches in the shared assertion region | The whole campaign | No |
| Sancov history map (highest bucketed count per edge) | The whole campaign | No |
Explorer::begin_seed(seed) clears the per-seed search structures and calls begin_run() for the root timeline. It deliberately keeps the compare-and-swap latches and the sancov history. The doc comment on the method states the rule: later seeds only get exploration effort for discoveries no earlier timeline made.
A Barren Seed Costs One Run
Follow a two-seed campaign on the maze workload:
seed 1, root run: discovers "exit found" on gate 3, "retry fired", "turned back"
-> productive, expands, explores up to 2400 timelines
seed 2, root run: same semantic states, all latches already taken
-> journal empty -> barren -> zero children
The second seed finishes after exactly one timeline. The report shows it in report.exploration.per_seed_timelines, a vector parallel to report.seeds_used. A typical print after a campaign:
per_seed_timelines: [1843, 1, 1]
The first seed consumed the budget. The two later seeds added nothing new and were not punished for it, but they did not get another 2400 runs either. max_runs_per_seed is a ceiling, not an allocation. A seed stops as soon as its frontier drains and no tracked state has an eligible exemplar left.
A later seed still earns a full exploration when its root draws a different profile. The network and storage profiles are drawn from the seed before the counted RNG stream starts, so seed 2 may run with partitions where seed 1 ran clean. If that root run discovers a state the first multiverse never reached, the frontier fills again and the seed gets the budget it needs.
What Cumulative Novelty Buys
The per-seed structures stay small and ordered. The frontier never mixes jobs from two root seeds, every recipe replays against its own seed, and the one-expansion rule and the continuation scoring reason about one multiverse at a time. A bug recipe always names the seed it belongs to:
pub struct BugRecipe {
pub seed: u64, // the root seed of the multiverse
pub recipe: Vec<(u64, u64)>,
}
Novelty, by contrast, is global. A state discovered under seed 1 is “already known” under seed 2, in the controller process and in every forked worker. The same latch guards the UntilCoverageStable convergence check, so running many seeds with exploration does not inflate assertion coverage with duplicates.
Interplay With UntilCoverageStable
The default iteration control is UntilCoverageStable { plateau_seeds: 10, max_iterations: 1000 }. After each root seed (and its exploration), the runner samples one progress signal:
| Signal | When | Source |
|---|---|---|
CodeCoverage | sancov counters are present (cargo xtask sim run) | with exploration: sancov_edges_covered(), the cumulative history map; without: sancov_edges_covered_live(), a union of live counters |
AssertionCoverage | no sancov | the number of assert_sometimes! and assert_reachable! sites that passed |
The campaign converges when every sometimes-style site has passed and the signal has not moved for plateau_seeds consecutive seeds. Because the history map is cumulative, the signal is monotonic. Barren seeds move it by zero, so ten barren seeds in a row end the campaign. The converged flag in ExplorationReport reports which way the loop stopped, and report.convergence_timeout is set when max_iterations ran out first.
The dungeon binary uses set_iterations(3) instead. Three multiverses are enough to show that the treasure depends on accumulated progress and not on a lucky root seed, and a fixed count keeps the CI wall clock predictable.
The Complete Picture
Put the pieces of this part together:
- A root seed runs. Its discoveries, stamped with RNG call counts, fill a journal.
- The controller expands the run once, with
branching_factorchildren anchored at the best discovery. - Workers replay each child recipe and continue with fresh randomness. Live processes stay at
1 + workers. - Every semantic state keeps three exemplars. When the frontier drains, continuations restart from the least-visited, deepest state.
- The seed stops at the budget ceiling or when nothing is left to continue.
- The next seed starts with an empty frontier and a full memory of what is already known.
The result is a campaign that spends compute where new behaviour is, in a fixed process footprint, with a replayable recipe for every bug. The next chapter applies this to a consensus protocol.
Exploring a Consensus Protocol
- Start With Workers 0
- Put Safety in Invariants
- Add Semantic Replay Anchors
- Combine Exploration With Broad Seeds
- Replay Every Failure
- Operational Limits
Paxos, Raft and replicated logs are where exploration pays off. Their worst bugs need a sequence: a leader change, then a stale acceptor, then a client retry inside the gap. The explorer finds such sequences when the workload gives it two different signals. Safety invariants catch the bug. Semantic progress assertions tell the frontier which prefixes are worth continuing. Code coverage reports that a branch ran, but it carries no RNG coordinate, so it never anchors a recipe.
Start With Workers 0
Validate a new integration without fork():
use moonpool_sim::{ExplorationConfig, SimulationBuilder, WorkloadCount};
let report = SimulationBuilder::new()
.processes(3, || Box::new(PaxosNode::new()))
.workloads(WorkloadCount::Fixed(1), |_| Box::new(PaxosWorkload::new()))
.invariant(AgreementInvariant::default())
.enable_exploration(ExplorationConfig {
workers: 0, // deterministic search order, no fork
max_runs_per_seed: 2_000,
branching_factor: 4,
max_frontier: 512,
max_recipe_len: 32,
})
.until_coverage_stable(10, 1_000)
.run()?;
Exploration needs factory workloads (workload_factory or workloads). A single .workload(instance) cannot be rebuilt for every continuation, so run() returns InvalidConfiguration. Processes are always factory-built, and a custom FaultInjector comes through fault_factory, so both work under exploration. Raise workers only in a standalone simulation binary on Linux or macOS, once the harness has no unrelated threads, sockets or files that a child could inherit.
Put Safety in Invariants
Emit the protocol’s facts as plain tracing events from the node code:
tracing::info!(target: "paxos", slot, ballot, value = %value_hash, "value_chosen");
Then check cross-node, cross-time properties after every simulation step with a cursor-based Invariant. The canonical pattern is crates/moonpool-sim/tests/leader_election.rs:
fn observe(&self, q: &dyn TraceQuery, _sim_time_ms: u64) {
let mut seen = self.seen.borrow_mut();
for e in q.since("leader_elected", &self.cursor) {
let term = e.u64("term").expect("leader_elected carries a term");
let leader = e.str("leader").expect("leader_elected carries a leader").to_owned();
if let Some(prior) = seen.get(&term) {
// One leader per term. A second claim is split-brain.
assert_always!(prior == &leader, format!("split-brain at term {term}: {prior} vs {leader}"));
} else {
seen.insert(term, leader);
}
}
}
The minimum useful set for Paxos:
| Property | Check |
|---|---|
| Agreement | Two value_chosen events for one slot never name different values. |
| Validity | Every chosen value was proposed by a client. |
| Acceptor persistence | After a promise survives sync_all, a reboot never reveals an older ballot. |
| Prefix consistency | Committed logs on any two nodes agree on their common prefix. |
| Client semantics | A retry never produces two successful outcomes for one non-idempotent command. |
Run the checks every step. A later state can hide a transient violation that a final check would miss. See Events and Invariants for the full pattern.
Add Semantic Replay Anchors
Discovery assertions name the protocol states the frontier should return to. Only the sometimes family creates discoveries. The always family records violations and a reporting watermark but never guides the search.
assert_sometimes!(leader_changed, "paxos leader changed");
assert_sometimes!(promise_rejected, "stale ballot was rejected");
assert_sometimes!(recovered_acceptor, "acceptor recovered persisted state");
assert_sometimes_all!("failover made progress", [
("old leader unavailable", old_leader_unavailable),
("new quorum formed", new_quorum_formed),
("client completed", client_completed),
]);
Watermarks give the explorer monotonic depth. Each improvement is a WatermarkImprovement discovery, so a later timeline can push the frontier instead of consuming one static milestone:
assert_sometimes_greater_than!(decided_slots, 32, "many slots decided");
assert_sometimes_greater_than!(ballot_changes, 4, "repeated ballot changes");
assert_sometimes_greater_than!(retry_depth, 3, "deep client retry chain");
Use assert_sometimes_each! for a small, bounded state vocabulary with a quality value:
assert_sometimes_each!("paxos transition",
[("phase", phase_code), ("fault regime", fault_regime_code)],
[("decided slots", decided_slots)]);
Keep identity keys coarse. The shared table holds 2048 assertion sites, 256 sometimes_each buckets, and at most 6 keys per site. Bucketing every node, ballot, slot and value exhausts the table and teaches the controller identifiers instead of protocol structure. Good identity dimensions: phase, role, quorum state, recovery mode, fault regime. Good quality values: decided slot count, ballot depth, retry depth, inverse replica lag.
Combine Exploration With Broad Seeds
Exploration is semantic-guided randomized testing. It is not model checking and not a proof of safety. Discovery latches are cumulative across seeds, so a static boolean milestone guides only the first seed that reaches it. Watermarks and buckets let later seeds still contribute progress.
Keep the default UntilCoverageStable control. It samples new root profiles (network, storage, attrition regimes) until the coverage signal plateaus, while exploration deepens each root. The two are complementary: breadth across profiles, depth inside each one.
Replay Every Failure
An exploration bug marks its root seed failed and lands in report.exploration.bug_recipes. Reproduce it with a fresh builder before diagnosing it:
let bug = report
.exploration
.as_ref()
.and_then(|e| e.bug_recipes.first())
.expect("exploration found a bug recipe");
let replay = SimulationBuilder::new()
.processes(3, || Box::new(PaxosNode::new()))
.workloads(WorkloadCount::Fixed(1), |_| Box::new(PaxosWorkload::new()))
.invariant(AgreementInvariant::default())
.replay_timeline(bug.seed, bug.recipe.clone())
.run()?;
assert_eq!(replay.failed_runs, 1);
If the replay does not reproduce, the harness is not deterministic. Audit for direct tokio:: calls, tokio::select! instead of moonpool_sim::select!, HashMap iteration order, std::time, threads, or I/O outside the providers. SimulationBuilder::check_determinism() runs each seed twice and reports a DeterminismViolation with the first diverging draw.
Operational Limits
Account for these before using the explorer as a CI gate:
| Limit | Consequence |
|---|---|
| Workers > 0 finish in wall-clock order | Each recipe replays exactly, but the search order and the set of found bugs can differ between campaigns. |
| A worker crash loses its journal | The recipe is kept as a crash reproducer, but discoveries it claimed are not expanded. |
| Sancov is a plateau signal only | Code coverage never anchors a recipe. Use assertions for guidance. |
| The 5-minute worker ceiling | A worker blocked outside the simulation is killed and recorded as a crash with its recipe. |
For a correctness-critical consensus suite today: keep workers: 0 in cargo nextest, run the forked binary through cargo xtask sim run, and replay every captured failure.
A Crash-Aware Journal
- The Problem It Solves
- Positions, Not Indexes
- Layout
- Committing: One Sync or Two
- Recovering
- Testing It Under Chaos::Storage
- On a Real Disk
moonpool-journal is a write-ahead journal built on nothing but the provider
traits and BlockFile. It
implements CLSTORE, the local storage layer of Protocol-Aware Recovery for
Consensus-Based Storage (Alagappan et al., FAST ’18). It is a real crate:
production hands it TokioStorageProvider, tests hand it the simulator’s
disk, and the code is the same. It is also the worked example of testing a
storage engine under Chaos::Storage.
The Problem It Solves
A log that finds a bad checksum usually truncates there. For a torn write at the tail that is correct: the entry was never acknowledged. For an entry that was acknowledged and later rotted, truncation silently throws away committed data. In a replicated system, the node that truncated can then win an election and erase that data cluster-wide. The two cases look identical if all the log has is the entry.
CLSTORE does three things about it. It detects damage (checksums, and a medium error read as damaged bytes). It disentangles a crash from corruption: every entry has a persist record stored far from it, and a damaged entry whose record survived was written, so it is corruption, not a torn write. And it identifies what was damaged: the record carries the entry’s identity, so a replication layer knows what to fetch from a peer.
Positions, Not Indexes
The journal knows no protocol. An entry is a u64 position and an
opaque 24-byte Id the caller chooses. A Paxos acceptor puts its slot in
the position and its ballot in the id. A Raft log puts its index and term.
Positions are written in any order, with gaps, and overwritten:
use moonpool_journal::{Batch, Journal, JournalConfig, JournalId};
let mut journal = Journal::create(provider, "wal", JournalId(0xACCE), JournalConfig::default(), b"promise=0").await?;
let mut batch = Batch::new();
batch
.put(7, ballot_id(3), "set x=7") // a new slot
.put(4, ballot_id(2), "set w=4") // a lagging acceptor catching up
.put(9, ballot_id(5), "set y=10") // a re-accept at a higher ballot
.set_meta("promise=5"); // the node-unique state
journal.commit(batch).await?;
That shape is what an acceptor or a lagging replica needs, and it is why the
journal stores state rather than a log of operations: there is no fold at
boot and no checkpoint. Batch::clear(range) removes positions (a Raft
suffix truncation). Batch::truncate_prefix(floor) drops everything below
a floor and frees whole segments from the front. Batch::set_meta replaces
the metainfo (at most META_MAX bytes, just under 4 KiB), kept in two
copies. There are no snapshots and no compaction.
A batch lands in one segment, so it must fit an empty one. A bigger batch is
refused with CommitError::BatchTooLarge before anything is written. A
caller with more to write than that (a replica catching up a long log) packs
its writes with Batch::fits_another:
let mut chunk = Batch::new();
for (position, payload) in puts {
if !chunk.fits_another(geometry, payload.len()) {
journal.commit(std::mem::take(&mut chunk)).await?; // every chunk fits
}
chunk.put(position, id, payload);
}
journal.commit(chunk).await?; // metainfo and floor go in the last one
Batch::fits(geometry) is the same bound for a staged batch. The default
Geometry is a 64 MiB segment with persist and entry logs of the same size
(507,904 records and 31 MiB of entries). Every batch starts on a fresh block
in both logs, so a segment holds 7,936 batches of one small entry.
Geometry::small() is 512 records and 64 KiB of entries, so tests roll over
segments quickly. Journal::open refuses a geometry other than the one the
journal was created with.
Layout
<dir>/meta metainfo copy A (block 0), copy B (block 16)
<dir>/seg-<first batch>.wal allocated whole:
header A | header B | persist log | guard gap | entry log
<dir>/spare-<gen>-<n>.wal a dead segment's file, kept for the next roll
Each segment holds two append-only logs. The persist log is a run of 64-byte records: kind, position, id, the entry’s offset, length and CRC, the batch, its record count and the record’s index. The entry log holds the payloads in arrival order, each behind a 64-byte header that repeats the record’s identity, so a misdirected entry never passes for the one a record names, and an intact entry can rebuild a lost record. Every batch starts on a fresh block in both logs, so no write ever touches a sector holding anything acknowledged. That one rule is what makes the crash model below survivable.
The meta file is separate. It is the one structure rewritten in place, and
it must outlive every segment. Its two copies are written one after the
other, each a durable write (write_at_durable), so at most one is in flight and a single
rotted copy never rolls the metainfo back. Journal::peek_meta(&provider, dir, id) reads it without opening the journal: no recovery, no repair, no
sync, so a probe never changes what the next open finds.
Committing: One Sync or Two
Durability picks the protocol, and every batch records which one it used:
| Mode | Protocol | Syncs | Last batch |
|---|---|---|---|
Batched | write(entries), write(records), fsync | 1 | ambiguous |
Ordered (default) | write_durable(entries), write_durable(records) | 2 | decided |
Batched is the paper’s protocol. With one sync, a crash can leave an intact
record beside a damaged entry exactly as corruption would. The paper’s
Appendix A proves the two cannot be told apart, so the last batch’s damaged
entries are reported State::Ambiguous and the replication layer decides.
The ambiguous tail stays ambiguous until the next batch commits, then
becomes Corrupt. Ordered writes the records only once the entries are
durable, so a record always proves its entry was synced.
Ordered issues each log’s write as one durable write
(StorageFile::write_at_durable): on Linux one pwritev2(RWF_DSYNC), which
on a direct-I/O segment whose blocks are written can become a single FUA
write instead of a write plus a cache flush. Two calls replace four, and the
durability argument is unchanged. A durable write covers its own range only,
and that range is the segment’s one unsynced write: every earlier commit was
durable before it returned, an open syncs every segment, a roll writes its
headers durably, and a failed commit poisons the journal. Data-only
durability is enough because a segment never changes size. Batched keeps
its plain writes and one sync: a durable write of the records alone would not
cover the entries.
A failed sync can leave pages clean in the page cache but not on the device
(fsyncgate): a later sync reports success, and reads serve the cached bytes.
Under one sync the device can then keep a record and lose its entry, a damaged
batch that no crash made. So a Batched commit whose sync fails writes its
batch again and syncs again, at most three syncs in all. The batch is not
acknowledged yet, so the rewrite is safe: a power loss during it can tear the
batch, which a power loss before the first sync could already do.
No Copy on the Way to the Disk
A commit encodes its batch in place. commit sizes the entry log’s
bytes first (Batch::fits already bounded them), allocates one zeroed,
block-aligned AlignedBuf, and encodes every entry header and payload
straight into it; the persist records go into a second one. Both move into
BlockFile::write_blocks_aligned / write_blocks_durable_aligned, which
hand them to the file’s owned write_at_aligned /
write_at_durable_aligned. On TokioStorageProvider the buffer moves into
the blocking task and back, so pwritev2(RWF_DSYNC) reads the journal’s own
memory: the bytes a caller staged are copied once, into the batch, and
never again. The segment headers, the metainfo copies and recovery’s
reads (the persist log, entries read in place) take the same owned path.
Before, a direct-I/O commit copied each log twice: from the journal’s
Vec into an aligned buffer, then into the provider’s bounce buffer.
let entries = std::mem::take(&mut batch.entries);
batch.entries = segment.write_durable(batch.entry_at, entries).await?; // moved, not copied
A buffer whose address missed a file’s memory alignment would be copied once into one that meets it; a block boundary meets every direct-I/O device the journal opens in practice.
Metainfo is written only after the batch is durable: copy A durably, then copy B durably. A crash can leave the batch durable without its metainfo, but never the metainfo without its batch. Anything true only once a batch is on disk (a chosen index, a watermark) can share that batch’s commit.
Hints Inside a Commit
The commit protocol names its dangerous moments with hint! from
moonpool-buggify. A batched commit names three:
batch.entries = segment.write(batch.entry_at, entries).await?;
// A power loss here tears entries no record points at yet.
moonpool_buggify::hint!("journal commit: entries written, not synced", COMMIT_HINT_PROB).await;
batch.records = segment.write(batch.persist_at, records).await?;
moonpool_buggify::hint!("journal commit: records written, not synced", COMMIT_HINT_PROB).await;
segment.sync().await?;
if let Some(meta) = meta {
moonpool_buggify::hint!("journal commit: batch synced, metainfo stale", COMMIT_HINT_PROB).await;
meta_file.update(meta).await?;
}
An ordered commit writes each log with one durable write, so the window
between a write and its sync lies inside a single call. The simulator names
it there: its write_at_durable (which the owned write_at_durable_aligned
calls by default) is write_at, then
hint!("storage: durable write landed, not synced") at the same 0.25, then
sync_data. The ordered commit keeps the metainfo moment of its own.
In production a hint is a no-op. In a simulation the HintSink decides
whether to crash the owning process at that exact moment, under the seed’s
attrition regime and swarm mask (see
hint!: Naming Fault Sites). The rate is
0.25 per call, higher than the default POINT_PROB, because a commit is
short and a cut inside it is the case recovery exists for. A harness with a
budget the simulator cannot see, such as the copies a replicated store can
lose before a crash is no longer survivable, publishes a HintVeto:
HintVeto::new(move |ip, label| copies_alive.load(Ordering::Relaxed) > quorum)
.publish(ctx.state());
The sink asks the veto last, after the regime admits the reboot, so a true
answer is a reboot.
Rolling Onto a Recycled Segment
When the active segment is full, the commit rolls onto a new one. How expensive should that be? A segment is 64 MiB. Write it with zeros and the commit waits for 64 MiB and two syncs; write it in a background task and the zeros still compete with every commit for the same disk. We measured a zero-fill running beside the commits: they ran five times slower, with 45 ms spikes.
So the journal writes no zeros at all. It does what PostgreSQL does with its
WAL: it recycles. When the floor, or later writes, leave the front
segment with nothing live, the journal renames that file to
spare-<generation>-<n>.wal and syncs the directory, instead of deleting
it. The next roll adopts the spare: it writes the two header blocks,
syncs them, renames the file to seg-<first batch>.wal and syncs the
directory. One small durable write and a rename. The file is already full
size, and every block in it was written before, so a commit into it costs
exactly what a commit into any written block costs.
With no spare (a journal that only grows), the roll creates the segment with
StorageFile::allocate, which is fallocate(2) on Linux: the space is
reserved, the range reads as zeros, and only the headers are written. A full
disk fails there, at the roll, and never inside a commit. A new segment’s
first pass does pay a little: the first durable write into each block also
records that the block is now written, a small metadata change. A recycled
segment pays none.
But a recycled file is full of old batches. Why are they not evidence?
- A batch older than its segment is stale. A segment named after its first batch only ever receives batches at or above that number, and batch numbers never repeat: the open generation lives in their high bits. Every record and entry a recycled file still holds is older than the segment it becomes, so the scan reads it like a never-written slot. The recovery table below is unchanged.
- No batch is written to a spare. Its headers are synced before its rename, so a crash during the adoption leaves the spare, or a whole empty segment, or the same file under either name. A recycled segment is renamed and its directory synced before anything is written into it, so it can never come back under its old name with new bytes in it.
- The commit acknowledges its batch only after the directory sync, so the segment’s name is durable before any batch in it is.
An open keeps one whole spare it finds (Recovery::spare_kept) and deletes
the others (Recovery::spares_removed). Journal::rolls() counts the rolls
of each kind. The commit side names two moments with hint! (“journal roll:
spare headers synced, not renamed”, “journal roll: spare renamed, directory
not synced”).
Recovering
Opening reads every persist record, locates each batch, walks the entry log to match damaged records with their entries, and judges each one:
| Entry | Persist record | Verdict |
|---|---|---|
| intact | valid | live |
| intact | damaged | live; the record is rebuilt from the entry |
| damaged | valid | Corrupt, or Ambiguous in a Batched last batch |
| damaged | damaged, last batch | torn by a crash: discarded |
| damaged | damaged, earlier | OpenError::DoubleFault: refuse to open |
A batch missing while later ones exist, or a log ending before the batch the
metainfo proves durable, is LostBatch. A missing meta file beside segments,
or two damaged copies, is MetaLost. Opening returns a Recovery that says
what it found and did: corrupt, ambiguous, torn, rebuilt,
meta_repaired (a metainfo copy rewritten from its twin),
headers_repaired, segments_removed, spares_removed and spare_kept. Only the last batch’s entries are
read at open. The rest are checked when read: read(position) returns
ReadError::Damaged { position, id } with the identity its record kept, and
replay(range) reports a damaged entry in place and goes on. A replay reads
entries that follow each other in one segment with one read, up to 1 MiB, and
judges each entry as read does: under direct I/O, a replay of a few thousand
entries costs a few reads, not one read per entry.
Three rules keep recovery’s own writes honest, and the crash loops found each of them. What opening keeps, it syncs, because a process that restarted without a power loss reads its predecessor’s unsynced writes from the page cache. A record never reaches the disk before its entry, so a rebuilt record is written only after the entries are synced. What an open declared the end stays the end: every open bumps an open generation in the metainfo, and a record a crash left unreadable can never read back later as evidence.
Testing It Under Chaos::Storage
The integration tests in tests/journal.rs run on SimWorld storage with
StorageConfiguration::fast_local(). One test per row of the recovery table
aims a bit flip with Journal::layout(position), which returns the record
and the entry as two LayoutRegions, each carrying the position as its
stripe. Journal::regions() lists every region a fault could hit, so a
harness aims faults by kind without knowing the format:
let focus = FaultFocus::new()
.background(0.1)
.layout(&journal.regions(), |region| {
if region.kind == Layout::RECORD { 8.0 } else { 4.0 }
});
The crash loops in tests/support/mod.rs commit random batches, cut the
writer off mid-commit with simulate_crash_for_process, sometimes crash
inside recovery itself, and judge every reopen against a ledger of what was
acknowledged and what was in flight. They run under two fault models:
- The paper’s: every unsynced sector ends up old or new, plus clean
crashes and correlated rollbacks (
clean_crash_probability,correlated_rollback_probability,garbage_fill_probability). - Moonpool’s full physics: sectors lost, latent read faults and shorn
writes on top (
crash_lost_probability,crash_latent_fault_probability,shorn_write_probability), with failed syncs and lost directory entries.
The promise is the same under both: nothing acknowledged is lost, changed or
reported damaged, and only a Batched batch in flight may come back
ambiguous. Each model runs for Ordered, Batched and a mode drawn afresh
every round. Floor raises free segments, so crashes land around recycling
and commits roll onto recycled segments; each model asserts that commits
rolled onto a recycled segment and that an open kept a spare. JOURNAL_CRASH_SEEDS raises the seed count and
JOURNAL_CRASH_SEED replays one.
The full simulation is journal.rs in moonpool-sim-examples
(cargo xtask sim run journal). One process owns the journal.
Chaos::Attrition crashes it over and over with prob_wipe: 0.0, since a
wiped disk is data loss no local journal can recover from.
Chaos::Storage(ChaosMode::Random) runs the disk’s fault families
underneath, with a storage_fault_mask that removes the ones a lone node has
no second copy to repair from:
.storage_fault_mask(
StorageFaultMask::all()
.without(StorageFault::Corruption)
.without(StorageFault::Eio)
.without(StorageFault::Misdirect)
.without(StorageFault::PhantomWrite)
.without(StorageFault::DiskFailure),
)
Floor raises recycle segments under the crashes, so the run reaches “an open found a recycled spare segment” and “a commit rolled onto a recycled segment”.
The node’s memory dies with it, so what it acknowledged lives in a Ledger
published in the iteration’s StateHandle. Every boot draws a durability
mode, opens the journal and checks it against that ledger:
assert_always!(written, "a position holds only a value written there");
assert_always!(
pending.batched && pending.touches(position),
"only the Batched batch in flight comes back damaged"
);
assert_always!(present, "no acknowledged write is lost");
A failed sync is an operating error, not a verdict: CommitError::Io says
whether the batch may be durable anyway (Durable::Unknown), the journal is
poisoned, and the node reopens it.
On a Real Disk
accept_log.rs (cargo run -p moonpool-journal --example accept_log) runs
the same journal on the real filesystem through TokioStorageProvider, in a
temporary directory. A Paxos acceptor stores accepts at their slots in
arrival order with the ballot as identity and its promise as metainfo, then
restarts and replays. Then it flips one byte of an acknowledged accept
behind the journal’s back. Reading it reports the slot damaged with the
ballot its record kept, the acceptor writes a peer’s copy at the same slot,
and a final reopen comes back clean. Nothing in the crate names the
simulator: the provider is the only thing that changes.
Does the provider change the speed? backends.rs answers it. It runs the
same journal on Tokio and on compio, with the same payloads, buffered and
with direct I/O (cargo run --release -p moonpool-journal --features compio-providers --example backends). On a 4-vCPU cloud VM, compio commits
one writer’s small records about 15 to 20% faster. Each Tokio file
operation goes through the blocking pool, which costs about 17 syscalls per
append against 3.5 on io_uring. With 16 journals in flight, Tokio commits
25 to 72% more, because compio runs all 16 on one thread. Recovery
takes the same time on both. The tables, the environment and the caveats
are in
docs/benchmarks/journal-backends.md.
The VM very likely hides most of the cost of a sync, so measure on your own
disk.
Assertion Reference
- Macro Summary
- Numeric Watermarks
- Discovery Kinds
- Contract Validation
- Table Limits
- Probe Macros
- Related Functions and Types
- Where to Use Which
All macros live in moonpool_sim (also through the moonpool facade with the sim feature). Accounting lives in moonpool-assertions, a dependency-free crate with a heap table by default and a MAP_SHARED table under exploration. No macro panics. A violated always-style assertion records a failure for the current seed, and the report lists it.
Macro Summary
| Macro | Form | Kind | Fails the seed when | Guides the explorer |
|---|---|---|---|---|
assert_always! | (cond, msg) or (cond, msg, { "k" => v, .. }) | Always | cond is false, or the site never ran | No |
assert_always_or_unreachable! | (cond, msg) or (cond, msg, {..}) | AlwaysOrUnreachable | cond is false | No |
assert_sometimes! | (cond, msg) | Sometimes | coverage miss: ran, never true | Yes: first pass |
assert_reachable! | (msg) | Reachable | coverage miss: never reached | Yes: first pass |
assert_unreachable! | (msg) or (msg, {..}) | Unreachable | reached at all | No |
assert_always_greater_than! | (val, thresh, msg [, {..}]) | NumericAlways | val > thresh is false | No |
assert_always_greater_than_or_equal_to! | same | NumericAlways | val >= thresh is false | No |
assert_always_less_than! | same | NumericAlways | val < thresh is false | No |
assert_always_less_than_or_equal_to! | same | NumericAlways | val <= thresh is false | No |
assert_sometimes_greater_than! | (val, thresh, msg) | NumericSometimes | coverage miss: val > thresh never true | Yes: watermark |
assert_sometimes_greater_than_or_equal_to! | same | NumericSometimes | coverage miss | Yes: watermark |
assert_sometimes_less_than! | same | NumericSometimes | coverage miss | Yes: watermark |
assert_sometimes_less_than_or_equal_to! | same | NumericSometimes | coverage miss | Yes: watermark |
assert_sometimes_all! | (msg, [(name, bool), ..]) | BooleanSometimesAll | coverage miss: never all true at once | Yes: frontier, combination |
assert_sometimes_each! | (msg, [(key, val), ..]) or (msg, [identity..], [quality..]) | bucket table | never (no contract) | Yes: bucket, quality |
Rules that apply to every macro:
msgis anyDisplayvalue. It identifies the site, so keep it stable and unique.- Numeric values go through
to_i64_saturating, so any integer type works. - The detail map
{ "k" => v, .. }prints[ASSERTION FAILED] msg (seed=N) | k=v ...on failure only. Values must implementDisplay. assert_sometimes_each!takes at most 6 identity keys per site (MAX_EACH_KEYS). The quality list is optional.
Numeric Watermarks
Every numeric site keeps one watermark, the best value seen for its direction:
| Macro family | Watermark tracks |
|---|---|
assert_always_greater_than[_or_equal_to]! | the minimum value (the worst case) |
assert_always_less_than[_or_equal_to]! | the maximum value (the worst case) |
assert_sometimes_greater_than[_or_equal_to]! | the maximum value |
assert_sometimes_less_than[_or_equal_to]! | the minimum value |
Always-style watermarks are reporting only. An improvement on a NumericSometimes watermark is a WatermarkImprovement discovery, so the explorer branches from it.
Discovery Kinds
The accounting layer fires on_discovery(kind, state_id) at most once per distinct discovery across every process that shares the table. The DiscoveryKind values and the macros that produce them:
| Kind | Produced by | Explorer priority |
|---|---|---|
WatermarkImprovement | a NumericSometimes watermark moves | progress (2) |
FrontierAdvance | more assert_sometimes_all! facts true at once than before | progress (2) |
BucketQuality | an assert_sometimes_each! bucket’s quality value improves | progress (2) |
BucketFirst | a new assert_sometimes_each! identity bucket | structured novelty (1) |
BooleanCombination | a new partial truth combination in assert_sometimes_all! | structured novelty (1) |
SometimesPass | first pass of assert_sometimes! or assert_reachable! | coverage (0) |
Contract Validation
validate_assertion_contracts() returns (always_violations, coverage_violations) after the campaign:
| Kind | Always violation | Coverage violation |
|---|---|---|
Always | fail_count > 0, or the site was never evaluated (must_hit) | |
AlwaysOrUnreachable | fail_count > 0 | |
Unreachable | pass_count > 0 | |
NumericAlways | fail_count > 0 | |
Sometimes | evaluated at least once, never passed | |
Reachable | never reached | |
NumericSometimes | evaluated, never passed | |
BooleanSometimesAll | frontier < frontier_target |
Table integrity counts as an always violation: dropped slot allocations, dropped bucket allocations, a slot conflict or a bucket conflict (two messages hashing to one slot). SimulationReport.dropped_assertion_allocations reports the dropped count.
Coverage violations do not fail a FixedCount campaign by themselves. They stop UntilCoverageStable from converging, and the report prints them.
Table Limits
| Constant | Value | Meaning |
|---|---|---|
MAX_ASSERTION_SLOTS | 2048 | distinct assertion sites per campaign |
MAX_EACH_BUCKETS | 256 | distinct assert_sometimes_each! identity buckets |
MAX_EACH_KEYS | 6 | identity keys per assert_sometimes_each! call |
Bucket a coarse vocabulary (phase, role, floor), not identifiers (node id, slot number).
Probe Macros
moonpool-assertions exports two lightweight probes for library code that must not depend on moonpool-sim:
| Macro | Form | Equivalent |
|---|---|---|
reachable! | reachable!("msg") | assert_reachable! |
sometimes! | sometimes!(cond, "msg") | assert_sometimes! |
moonpool-sim re-exports them as moonpool_sim::reachable and moonpool_sim::sometimes.
Related Functions and Types
| Item | Where | Purpose |
|---|---|---|
assertion_results() -> BTreeMap<String, AssertionStats> | moonpool_sim | per-message { total_checks, successes } |
reset_assertion_results() | moonpool_sim | clear the table (the runner does this per campaign) |
has_always_violations(), reset_always_violations() | moonpool_sim | the per-run always flag |
validate_assertion_contracts() | moonpool_sim | the rules above |
AssertKind, AssertCmp | moonpool_assertions, re-exported | the kind and comparison enums |
AssertionDetail | moonpool_sim report | msg, kind, pass_count, fail_count, watermark, frontier, frontier_target, combinations_seen, status |
AssertionStatus | moonpool_sim report | Fail, Miss, Pass |
BucketSiteSummary | moonpool_sim report | msg, buckets_discovered, total_hits per sometimes_each site |
SimulationReport.assertion_details | report | one AssertionDetail per slot |
SimulationReport.bucket_summaries | report | one BucketSiteSummary per site |
SimulationReport.assertion_violations | report | always-style violations found by validation |
SimulationReport.coverage_violations | report | sometimes-style misses |
Where to Use Which
| Situation | Macro |
|---|---|
| An invariant that must hold on every evaluation | assert_always! |
| An invariant on a path that may never run under some seeds | assert_always_or_unreachable! |
| A branch the chaos must reach (retry, timeout, recovery) | assert_sometimes! or assert_reachable! |
| A branch that must never run | assert_unreachable! |
| A bound (queue depth, latency, replica lag) | assert_always_less_than! and friends |
| Depth the explorer should push (level reached, slots decided) | assert_sometimes_greater_than! |
| Several facts that must coincide at least once | assert_sometimes_all! |
| Coverage per identity with a quality value | assert_sometimes_each! |
See Assertions: Finding Bugs for the narrative and The Frontier Controller for how discoveries drive exploration.
Crate Map
The workspace has 16 members at version 0.8.0. Application code depends on moonpool (the facade) or directly on the crate it needs.
Dependency Graph
moonpool (facade)
|- moonpool-core provider traits, core types
|- moonpool-sim (feature sim) simulation runtime
| |- moonpool-core [deterministic-select]
| |- moonpool-assertions
| |- moonpool-buggify
| `- moonpool-explorer (feature exploration)
| `- moonpool-assertions
|- moonpool-hyper (feature hyper) -> moonpool-core
|- moonpool-prometheus (feature prometheus) -> moonpool-core
`- moonpool-rpc (feature rpc) -> moonpool-core, moonpool-buggify
`- moonpool-rpc-derive (feature rpc-derive)
moonpool-journal -> moonpool-core, moonpool-buggify (not in the facade)
moonpool-rpc-sim -> moonpool-rpc, moonpool-sim, moonpool-core (unpublished campaigns)
moonpool-sim-examples -> moonpool-sim, moonpool-hyper, moonpool-prometheus, moonpool-journal
moonpool-wasm-demo -> moonpool-sim (no default features)
moonpool-calibrate -> no moonpool crate (standalone CLI)
xtask -> no moonpool crate
Production builds keep the simulation out: moonpool = { default-features = false, features = ["tokio"] }. moonpool-sim is never a dependency of moonpool-rpc, moonpool-hyper, moonpool-journal or moonpool-prometheus (only a dev-dependency).
Library Crates
| Crate | Role | Key public items | Features |
|---|---|---|---|
moonpool | Facade. Re-exports moonpool_core::* and, with sim, moonpool_sim::*. Modules hyper, prometheus, rpc behind features. prelude with Process, SimContext, SimulationBuilder, Workload. | everything below | default = ["sim", "tokio"], sim, tokio, hyper, prometheus, rpc, rpc-derive, rpc-tls, rpc-jwt |
moonpool-core | Provider traits and core types. wasm-clean with all features off. | TimeProvider, TaskProvider, NetworkProvider, TcpListenerTrait, RandomProvider, StorageProvider, StorageFile, SystemProvider, Providers, Resolver, Detach, JoinError, OpenOptions, DirectIo, IoConstraints, AlignedBuf, BlockFile, LayoutRegion, SystemSample, DiskCounters, SimulationError, SimulationResult, MetricsSource, MetricSample, MetricValue, select!, Tokio* providers | default = ["tokio-providers", "select"]; tokio-providers = tokio-task + tokio-time + tokio-net + tokio-fs + tokio-random + tokio-system; select; deterministic-select |
moonpool-sim | Simulation runtime: executor, SimWorld, network and storage simulation, chaos, runner, assertions wiring, observability. | SimulationBuilder, SimContext, Process, Workload, FaultInjector, FaultContext, Chaos, ChaosMode, IterationControl, SimulationReport, SimWorld, SimProviders, Invariant, TraceQuery, assertion macros, buggify_knob!, ExplorationConfig | default = ["tokio-providers", "exploration"] |
moonpool-assertions | Assertion accounting. Pure std, zero dependencies, wasm-able. Heap table by default, MAP_SHARED overlay under exploration. | AssertKind, AssertCmp, DiscoveryKind, DiscoveryHooks, reachable!, sometimes!, MAX_ASSERTION_SLOTS, MAX_EACH_BUCKETS, MAX_EACH_KEYS | none |
moonpool-buggify | Fault-injection macros and state. Pure std, zero dependencies, wasm-able. Inert until a random source is installed. | buggify!, buggify_with_prob!, buggify_fault_with_prob!, buggify_named!, buggify_pick!, buggify_range!, hint!, cpu_charge, set_activation, buggify_init, buggify_reset, is_simulated | none |
moonpool-explorer | Frontier exploration controller: recipes, bounded worker pool, shared memory, sancov. libc only. Never wasm. | Explorer, ExplorationConfig, ExploreJob, Recipe, DiscoveryEvent, format_timeline, parse_timeline, set_rng_count_hook, sancov_is_available, sancov_edges_covered, explorer_is_child | none |
moonpool-hyper | hyper 1.x over the providers. Generic over P: Providers. | HyperIo, HyperExecutor, HyperTimer, TowerToHyperService, ReconnectingChannel, H2Channel, ChannelConfig, KeepAlive, H2Server, H2ServerConfig, DEFAULT_DRAIN_TIMEOUT | default = ["client", "server"] |
moonpool-rpc | Typed dynamic-endpoint RPC over the providers. | RpcDriver, RpcHandle, ServiceClient, RequestStream, IncomingRequest, ReplyHandle, ServiceRef, InterfaceRef, ServiceGroup, RpcConfig, EndpointQueue, ResourceLimits, FailureMonitor, RpcError, ErrorReason, Wire, CodecId, Connector, Acceptor, Plaintext, #[moonpool_rpc::service] | default = ["prost"], prost, derive, tls, jwt |
moonpool-rpc-derive | Proc macro behind moonpool-rpc/derive. | service attribute | none |
moonpool-prometheus | Prometheus registry adapter: one PrometheusSource per node, scraped into the report. | PrometheusSource, SimCounter, SimCounterVec, SimGauge, SimGaugeVec, SimHistogram, SimHistogramVec, SimTimer | none |
moonpool-journal | CLSTORE write-ahead journal over StorageProvider. Positions in any order, crash told apart from corruption, identified damage, three hint! moments per batched commit, durable writes (pwritev2(RWF_DSYNC)) on the ordered path, recycled segments (no zero-fill). | Journal, Batch, JournalConfig, JournalId, Durability, Geometry, Id, ID_SIZE, State, Recovery, Rolls, Layout, Entry, CommitError, OpenError, ReadError, Durable, BLOCK, RECORD_SIZE, META_MAX, MAX_BATCH_RECORDS | none |
Binary and Harness Crates
| Crate | Role | Binaries |
|---|---|---|
moonpool-sim-examples | Runnable simulation workloads that xtask discovers. | sim-maze-explore, sim-dungeon-explore, sim-axum-web, sim-metrics-service, sim-topology, sim-tonic-grpc, sim-journal |
moonpool-explorer | Also ships the explorer’s own ladder scenario. | sim-frontier-explore |
moonpool-rpc-sim | Simulation harness, oracles and campaigns for moonpool-rpc. Unpublished. | sim-rpc-foundations, sim-rpc-delivery, sim-rpc-interfaces, sim-rpc-balance, sim-rpc-streams, sim-rpc-security, sim-rpc-qualification |
moonpool-calibrate | Measures real storage and network latency on the host and prints LatencyDistribution constants as Rust. Depends on no moonpool crate. | moonpool-calibrate |
moonpool-wasm-demo | Browser demo embedded in the book. run_seed(seed), run_seed_json(seed). | wasm library |
xtask | Cargo automation: the simulation binary runner. See Running Simulation Binaries with xtask. | cargo xtask |
The moonpool facade also carries examples: retrying_worker and metastable_grpc_retry_storm. moonpool-rpc carries recruitment, balanced_mutation and stream_saturation.
Choosing a Dependency
| You want | Depend on |
|---|---|
| Write a service that runs in production and in simulation | moonpool with default-features = false, features = ["tokio"] in the library, moonpool (defaults) in dev-dependencies |
| Write a library with no simulation dependency, but with probes | moonpool-core (no default features), moonpool-assertions, moonpool-buggify |
| Run simulations from a test or binary | moonpool-sim or moonpool with sim |
| HTTP/2, gRPC through tonic | moonpool-hyper or moonpool with hyper |
| Typed RPC between processes | moonpool-rpc or moonpool with rpc (rpc-derive for the attribute) |
| Report your Prometheus metrics in the simulation report | moonpool-prometheus or moonpool with prometheus |
| A crash-aware log for a consensus node | moonpool-journal |
Compile to wasm32-unknown-unknown | moonpool-sim with default-features = false |
Validation Commands
| Command | Checks |
|---|---|
nix develop --command cargo fmt | formatting |
nix develop --command cargo clippy | pedantic lints, all crates |
nix develop --command cargo nextest run | all tests |
nix develop --command cargo clippy -p moonpool-sim --no-default-features --all-targets -- -D warnings | the feature-less sim |
nix develop --command cargo check --target wasm32-unknown-unknown -p moonpool-sim --no-default-features | the wasm build |
nix develop --command cargo xtask sim run-all | every simulation binary with sancov |
nix develop --command mdbook build book/ | this book |
Configuration Reference
- SimulationBuilder
- Attrition
- Outage
- Gray Failure
- CPU Model
- Locality
- NetworkConfiguration
- ChaosConfiguration (network)
- StorageConfiguration
- Aiming Storage Faults
- ExplorationConfig
- Buggify
Every value on this page is read from the current source. The code wins when the two disagree. Builder-level options come from SimulationBuilder. World-level options (network and storage profiles) are drawn per seed by the runner or set on SimWorld directly in low-level tests.
SimulationBuilder
| Method | Default | Purpose |
|---|---|---|
new() | Start a builder. | |
processes(count, factory) | Register one server group. count is usize or RangeInclusive<usize> (ProcessCount). The g-th group lives on 10.0.{g+1}.x. Duplicate group names panic. | |
cluster(LocalityConfig, factory) | Register a group whose count comes from a sampled topology. The group may draw zero processes. | |
tags(&[(key, &[values])]) -> Result<Self> | Round-robin tag dimensions over the last registered group. | |
workload(instance) | One test driver instance. Not valid with exploration or check_determinism. | |
workload_factory(|| Box<dyn Workload>) | A fresh driver per run. | |
workloads(WorkloadCount, |i| Box<dyn Workload>) | Fixed(n) or Random(range) drivers per run. | |
enable_chaos([Chaos, ..]) | no chaos | Enable chaos surfaces (table below). |
chaos_duration(Duration) | none | The fault-generation window. Required by fault_factory. After it, enter_recovery_mode turns off every configuration-driven family and heals held partitions. |
chaos_until_run_ends() | off | Chaos ends when the last workload’s run() returns, chaos_duration its cap. An early run() stops no sibling, and check() runs on the live processes after the cutoff. |
fault_factory(|| Box<dyn FaultInjector>) | A custom injector per run. run() returns InvalidConfiguration without chaos_duration. | |
network_fault_mask(NetworkFaultMask) | all() | Remove network fault families after the per-seed draw. |
storage_fault_mask(StorageFaultMask) | all() | Remove storage fault families after the per-seed draw. |
replicated_storage_faults(ReplicatedFaults) | none | Bound storage damage to a tolerated set of failure domains. |
swarm_operations() | off | Draw a per-seed subset of each workload’s operation alphabet. |
link_latency(LinkLatencyConfig) | none | Locality-aware link latency. |
tcp_send_window_bytes(usize) | 64 KiB | Byte window per stream direction. |
accept_backlog_capacity(usize) | 128 | Unaccepted connections per listener. Must be above 0. |
invariant(impl Invariant) | Run a cursor-based check after every step. | |
invariant_fn(name, |q, time_ms| ..) | Closure form of invariant. | |
trace_level(LevelFilter) | INFO | Capture level for the timeline. |
metrics_factory(..) | One MetricsSource per node. | |
metric(MetricQueryPlan) | A metric query reported per campaign. | |
set_iterations(n) | IterationControl::FixedCount(n). | |
until_coverage_stable(plateau_seeds, max_iterations) | 10, 1000 | IterationControl::UntilCoverageStable. This is the default. |
set_debug_seeds(Vec<u64>) | Pin seeds. They run first. | |
run_time_budget(Duration) | 1 simulated hour | A run past this budget is reported as a stall. |
replay_timeline(seed, recipe) | One seed, one iteration, the recipe installed as RNG breakpoints. | |
enable_exploration(ExplorationConfig) | off | Multiverse exploration. Needs factory workloads. Panics on a zero bound. |
check_determinism() | off | Run every seed twice and fail on a diverging draw. Needs factory workloads. |
run() -> SimulationResult<SimulationReport> | Run the campaign. |
There is no builder method that takes a NetworkConfiguration or StorageConfiguration. The runner draws both per seed. Low-level tests set them on SimWorld with new_with_network_config, set_network_config, set_storage_config and set_process_storage_config.
Enums
| Enum | Variants |
|---|---|
IterationControl | FixedCount(usize), UntilCoverageStable { plateau_seeds, max_iterations } |
ProcessCount | Fixed(usize), Range(RangeInclusive<usize>) |
WorkloadCount | Fixed(usize), Random(Range<usize>) |
ChaosMode | Random (every family on, rates drawn per seed), Swarm (a per-seed subset of families) |
Chaos | Network(ChaosMode), Storage(ChaosMode), Attrition { config, mode }, Outage { config, mode }, BuggifyKnobs, Cpu(ChaosMode), GrayFailure { config, mode } |
Without Chaos::Network, the network is not fault-free. It uses NetworkConfiguration::default(), whose ChaosConfiguration::default() keeps random closes, clock drift, buggified delays and probabilistic connect failures on, as FoundationDB does. Without Chaos::Storage, storage uses StorageConfiguration::default(), where every fault probability is 0.
Draw Order
Every random decision is a counted draw on the one simulation stream. The runner draws in this order per seed:
- Network profile and storage profile, before the reset that starts the RNG count. A replay breakpoint can never land on them.
- The world reset.
- Swarm operation masks (when enabled).
- Attrition, outage, replicated faults, CPU model and gray-failure regimes.
- The run itself.
Adding a draw anywhere shifts every later seed. That is intended. The same seed must still replay identically.
Attrition
Attrition has no Default. Set every field:
| Field | Type | Default when None | Meaning |
|---|---|---|---|
max_dead | usize | Simultaneously dead processes among this injector’s victims. | |
prob_graceful | f64 | Weight for RebootKind::Graceful. | |
prob_crash | f64 | Weight for RebootKind::Crash. | |
prob_wipe | f64 | Weight for RebootKind::CrashAndWipe. | |
recovery_delay_ms | Option<Range<usize>> | 1000..10000 | Restart delay after a kill. |
grace_period_ms | Option<Range<usize>> | 2000..5000 | Time between token cancel and force kill. |
scope | AttritionScope | PerProcess | PerProcess, PerMachine, PerZone, PerDatacenter. |
victims | AttritionVictims | Any | Any, Group(String) via ::group(name), Tagged { key, value } via ::tagged(key, value). |
The weights are relative. Attrition::swarm_for_seed() (used by ChaosMode::Swarm) draws 6 times: max_dead becomes 0 with probability 0.5, each weight is masked with probability 0.5, the recovery delay is scaled between 50% and 200%, and the scope is drawn among the scopes the topology supports.
Outage
Outage::groups(["acceptors", ..]) builds the default regime:
| Field | Default | Meaning |
|---|---|---|
groups | as given | Process groups that lose power together. |
probability | 1.0 | Chance a seed has the outage at all. |
start | 0s..10s | When it strikes, from the start of the chaos window. A draw past the window means no outage. |
down | 1s..10s | How long each victim stays down. |
straggler | None | One victim that stays down longer. Must start at or after down’s end. |
At most one outage per run. Under Swarm the outage is dropped on half the seeds and its straggler on half the rest. A workload publishes OutageVeto::new(|victims: &[String]| bool) through ctx.state() under OUTAGE_VETO_KEY ("moonpool:outage-veto") to hold an outage. The simulator retries every VETO_RETRY (1 ms). The landed outage is published as OutageLanded { at, victims, down, straggler } under OUTAGE_STATE_KEY ("moonpool.outage").
Gray Failure
GrayFailure::new(max_slow):
| Field | Default | Meaning |
|---|---|---|
victims | AttritionVictims::Any | Which processes may be slowed. |
max_slow | as given | Machines slow at the same time. Use the tolerated loss f. |
window | 0.3..=1.0 | Slow window as a fraction of the chaos window. |
cpu | 10..=1000 | CPU slowdown factor range. |
disk | 5..=100 | Disk latency factor range. |
network | 2..=20 | Network latency factor range. |
kinds | GrayKinds::ALL | cpu, disk, network flags. Swarm masks each per seed. |
SimContext::slowness(ip) and FaultContext::slowness(ip) return the current Slowness { cpu, disk, network }. Slowness::HEALTHY is all ones.
CPU Model
Chaos::Cpu(mode) installs a CpuModel per seed:
| Field | Drawn from | Meaning |
|---|---|---|
poll_cost | POLL_COST_NS = 500..5001 ns | Cost of one poll of one task. |
sent_byte_cost_ps | BYTE_COST_PS = 500..5001 ps | Cost per byte sent. |
written_byte_cost_ps | BYTE_COST_PS = 500..5001 ps | Cost per byte written to disk. |
CpuModel::random_for_seed() draws 3 times. CpuModel::swarm_for_seed() -> Option<CpuModel> draws one coin (0.5) and then the three costs. cpu_charge(Duration) adds explicit CPU time from application code. SimWorld::set_cpu_model installs a model by hand.
Locality
LocalityConfig::new(datacenters, zones_per_datacenter, machines_per_zone, processes_per_machine). Each argument is a usize or a RangeInclusive<usize> sampled per seed. DomainLevel is Datacenter, Zone or Machine. LinkClass is SameMachine, SameZone, SameDatacenter or CrossDatacenter.
LinkLatencyConfig::default():
| Link class | Latency |
|---|---|
same_machine | 10 µs to 50 µs |
same_zone | 100 µs to 500 µs |
same_datacenter | 500 µs to 2 ms |
cross_datacenter | 20 ms to 80 ms |
NetworkConfiguration
| Field | Default | fast_local() |
|---|---|---|
bind_latency | Uniform 50 µs to 150 µs | 1 µs to 10 µs |
accept_latency | Uniform 1 ms to 6 ms | 1 µs to 10 µs |
connect_latency | Uniform 1 ms to 11 ms | 1 µs to 10 µs |
write_latency | Uniform 100 µs to 600 µs | 1 µs to 10 µs |
link_latency | None | None |
tcp_send_window_bytes | 65536 (DEFAULT_TCP_SEND_WINDOW_BYTES) | same |
accept_backlog_capacity | 128 (DEFAULT_ACCEPT_BACKLOG_CAPACITY) | same |
chaos | ChaosConfiguration::default() | ChaosConfiguration::disabled() |
NetworkConfiguration::random_for_seed() and swarm_for_seed() draw the chaos block (below). disable_fault_injection() is what recovery mode calls.
LatencyDistribution variants: Uniform { start, end }, Exponential { min, mean }, Bimodal { fast_range, slow_range, slow_probability }. sample_latency and sample_duration draw from them.
ChaosConfiguration (network)
| Field | default() | random_for_seed() range |
|---|---|---|
clog_rate | 0.0 | 0.05 to 1 clogs per simulated second |
clog_duration | 100 ms to 300 ms | start 50 to 300 ms, end 100 to 500 ms |
partition_rate | 0.0 | 0.05 to 0.5 partitions per simulated second |
partition_duration | 200 ms to 2 s | start 100 ms to 1 s, end 500 ms to 3 s |
bit_flip_probability | 0.0 | 0.00001 to 0.0002 |
bit_flip_min_bits | 1 | 1 |
bit_flip_max_bits | 32 | 32 |
bit_flip_cooldown | 0 | 0 to 100 ms |
partial_write_max_bytes | 1000 | 100 to 2000 |
partial_read_max_bytes | 1000 | 100 to 2000 |
random_close_probability | 0.00001 | 0.000001 to 0.0001 |
random_close_cooldown | 5 s | 1 s to 10 s |
random_close_explicit_ratio | 0.3 | 0.20 to 0.40 |
black_hole_probability | 0.0 | 0.000001 to 0.0001 |
black_hole_cooldown | 5 s | 1 s to 10 s |
clock_drift_enabled | true | true |
clock_drift_max | 100 ms | 50 ms to 150 ms |
buggified_delay_enabled | true | true |
buggified_delay_max | 100 ms | 50 ms to 150 ms |
buggified_delay_probability | 0.25 | 0.20 to 0.30 |
connect_failure_mode | Probabilistic | one of Disabled, AlwaysFail, Probabilistic |
connect_failure_probability | 0.5 | 0.40 to 0.60 |
max_pair_latency | 0 to 0 (off) | 0 to 0..100 ms |
partition_strategy | Random | one of the 7 strategies |
disabled() zeroes every probability and turns every flag off. swarm_for_seed() draws random_for_seed() then 9 coins (0.5 each) that keep or drop clog, partition, bit flip, random close, connect failure, clock drift, buggified delay, pair latency and black hole. apply_buggify_knobs() (under Chaos::BuggifyKnobs) spikes clog to 2..5 per second, partition to 0.5..2 per second, random close to 0.01..0.1, black hole to 0.01..0.1, and bit flip to 0.001..0.01. It spikes a family only when it was already above 0, so a family the swarm turned off stays off.
PartitionStrategy: Random, UniformSize, IsolateSingle, IsolateZone, IsolateDatacenter, AsymmetricSend, AsymmetricRecv. ConnectFailureMode: Disabled, AlwaysFail, Probabilistic.
StorageConfiguration
| Field | default() | random_for_seed() range | fast_local() |
|---|---|---|---|
iops | 25 000 | 10 000 to 100 000 | 1 000 000 |
bandwidth (bytes/s) | 150 000 000 | 50 MB/s to 500 MB/s | 1 GB/s |
read_latency | 50 µs to 200 µs | 1 µs | |
write_latency | 100 µs to 500 µs | 1 µs | |
sync_latency | 1 ms to 5 ms | 1 µs | |
read_corruption_probability | 0.0 | 0 to 0.001 | 0 |
write_corruption_probability | 0.0 | 0 to 0.001 | 0 |
read_eio_probability | 0.0 | 0 to 0.001 | 0 |
write_eio_probability | 0.0 | 0 to 0.001 | 0 |
clean_crash_probability | 0.1 | ||
correlated_rollback_probability | 0.25 | ||
correlated_rollback_max_run | 8 | ||
crash_lost_probability | 0.0 | 0 to 0.1 | 0 |
crash_latent_fault_probability | 0.0 | 0 to 0.05 | 0 |
shorn_write_probability | 0.0 | 0 | |
length_survives_crash_probability | 0.5 | ||
garbage_fill_probability | 1.0 | 0.5 | |
barrier_violation_probability | 0.0 | 0 | |
misdirect_write_probability | 0.0 | 0 to 0.0001 | 0 |
misdirect_read_probability | 0.0 | 0 to 0.0001 | 0 |
phantom_write_probability | 0.0 | 0 to 0.0002 | 0 |
sync_failure_probability | 0.0 | 0 to 0.0005 | 0 |
direct_io_alignment | 4096 | ||
direct_io_supported | true | ||
unsynced_dir_entry_loss_probability | 0.0 | 0 to 0.005 | 0 |
short_transfer_probability | 0.0 | 0 to 0.002 | 0 |
disk_stall_probability | 0.0 | 0 to 0.001 | 0 |
disk_stall_duration | 0 | 50 ms to 500 ms | |
disk_throttle_probability | 0.0 | 0 to 0.001 | 0 |
disk_throttle_duration | 0 | 500 ms to 5 s | |
disk_throttle_iops_multiplier | 1.0 | 2 to 20 | |
disk_throttle_bandwidth_multiplier | 1.0 | 2 to 20 | |
disk_failure_probability | 0.0 | 0 to 0.0001 | 0 |
dropped_mutation_discard_probability | 0.0 | 0 to 0.49 | 0 |
swarm_for_seed() draws random_for_seed() then 15 coins over the families. apply_buggify_knobs() spikes iops to 100..5000, bandwidth to 1 MB/s..20 MB/s, sync failure to 0.05..0.2, disk stall to 0.1..0.5 with 100 ms to 500 ms episodes, throttle to 0.1..0.5 with 1 s to 5 s episodes, and disk failure to 0.001..0.01. disable_fault_injection() zeroes every family, including barrier violation. Sectors are SECTOR_SIZE = 512 bytes.
Aiming Storage Faults
| Type | API |
|---|---|
StorageEligibilityMask | Arc<dyn Fn(&str, u64) -> bool> over (path, sector). SimWorld::set_storage_eligibility_mask, clear_storage_eligibility_mask. |
FaultFocus | FaultFocus::new().background(w).spot(path, bytes, w).layout(regions, |r| w). weight(path, sector) is the heaviest spot or the background. SimWorld::set_fault_focus, clear_fault_focus, SimStorageProvider::focus_faults. |
LayoutRegion | { path, bytes: Range<u64>, kind: &'static str, stripe: Option<u64>, shard: Option<String> } from moonpool_core. Published with SimWorld::publish_layout. |
ReplicatedFaults | ReplicatedFaults::new(DomainLevel).tolerance(n).group("name"). The pattern per seed is a FaultPattern: Minority { domains }, Striped { domains, local }, Rolling { .. }, Spared. |
ExplorationConfig
| Field | Default | Meaning |
|---|---|---|
workers | 0 | Forked workers. 0 runs in-process and deterministic. |
max_runs_per_seed | 512 | Ceiling on timelines per root seed. |
branching_factor | 4 | Children per productive run. |
max_frontier | 1024 | Queued jobs. |
max_recipe_len | 64 | Depth in replay segments. |
validate() rejects a zero in any bound except workers. The worker wall-clock ceiling is DEFAULT_WORKER_TIMEOUT = 5 minutes (Explorer::set_worker_timeout).
Buggify
| Item | Value |
|---|---|
buggify_init(activation_prob) | The runner calls buggify_init(0.5) per run: each site is active on half the seeds. |
buggify!() | Fires with probability 0.25 at an active site. |
buggify_with_prob!(p) | Fires with probability p at an active site. |
buggify_fault_with_prob!(p) | Same, but silent in recovery mode. |
buggify_named!("label", p) | Same, under a label that set_activation(label, bool) controls. |
buggify_pick!(p, n) -> Option<usize> | An index below n, or None. |
buggify_range!(p, range) -> Option<u64> | A value in range, or None. |
buggify_knob!(default, lo..hi) | moonpool-sim only. The value in lo..hi with probability 0.25 at an active site, else default. |
hint!("label"), hint!("label", p) | A fault moment. Default point probability 0.05. A no-op in production. |
See Fault Reference for what each fault does.
Fault Reference
- Network Faults
- Storage Faults
- Process Faults
- CPU and Gray Failures
- Buggify Knob Spikes
- Fault Events in the Timeline
- Presets
Every fault the simulator can inject, with where it is configured and how it shows up. Rates and defaults are in the Configuration Reference. Every fault is drawn from the one seeded stream, so it replays exactly.
Network Faults
Enabled by Chaos::Network(mode), limited by network_fault_mask. Each family is one NetworkFault bit.
NetworkFault | Bit | What happens | Config fields |
|---|---|---|---|
Clog | 1 << 0 | One IP’s sends, receives or both are held for a duration, then resume. Arrivals are a rate per simulated second. | clog_rate, clog_duration |
Partition | 1 << 1 | Directed partitions. Sends between the chosen IPs are stalled until healed. Arrivals are a rate per simulated second, one partition at a time. | partition_rate, partition_duration, partition_strategy |
BitFlip | 1 << 2 | 1 to 32 bits of in-flight data are flipped. | bit_flip_probability, bit_flip_min_bits, bit_flip_max_bits, bit_flip_cooldown |
RandomClose | 1 << 3 | A connection closes during I/O. A share is an explicit close, the rest a reset. | random_close_probability, random_close_cooldown, random_close_explicit_ratio |
ConnectFailure | 1 << 4 | connect fails or hangs. | connect_failure_mode, connect_failure_probability |
ClockDrift | 1 << 5 | Each node’s clock drifts up to a bound. | clock_drift_enabled, clock_drift_max |
BuggifiedDelay | 1 << 6 | Timers fire late by an extra random delay. | buggified_delay_enabled, buggified_delay_max, buggified_delay_probability |
PairLatency | 1 << 7 | One IP pair gets permanent extra latency. | max_pair_latency |
BlackHole | 1 << 8 | A connection whose sends vanish silently and forever. | black_hole_probability, black_hole_cooldown |
Partial reads and writes (partial_write_max_bytes, partial_read_max_bytes) are TCP behaviour exercised through buggify. The mask does not touch them.
PartitionStrategy picks the victims: Random, UniformSize, IsolateSingle, IsolateZone, IsolateDatacenter, AsymmetricSend, AsymmetricRecv. The locality strategies need cluster(..) topology.
NetworkFaultMask::all(), none(), .with(fault), .without(fault), .contains(fault). Recovery mode (SimWorld::enter_recovery_mode) is the none() mask plus healing of held partitions.
Manual network control
| Method | On | Effect |
|---|---|---|
partition_pair(from, to, duration) | SimWorld | Drop sends from from to to for duration. |
partition_send_from(ip, duration) | SimWorld | Drop every send from ip. |
partition_recv_to(ip, duration) | SimWorld | Drop every send to ip. |
restore_partition(from, to) | SimWorld | Heal one direction. |
is_partitioned(from, to) | SimWorld | Query. |
partition(a, b), heal_partition(a, b), is_partitioned(a, b) | FaultContext | The same, by IP string, from a custom injector. |
Storage Faults
Enabled by Chaos::Storage(mode), limited by storage_fault_mask. Fault coordinates are a file path plus a flat 512-byte sector offset.
StorageFault | What happens | Config fields | Reported as |
|---|---|---|---|
Corruption | A read or write plants a latent, deterministic sector corruption. | read_corruption_probability, write_corruption_probability | StorageFaultKind::ReadCorruption, WriteCorruption |
Eio | A read or write fails with an I/O error. The bytes are not corrupt. | read_eio_probability, write_eio_probability | EioRead, EioWrite |
Misdirect | A read is served from, or a write lands at, the wrong offset. | misdirect_read_probability, misdirect_write_probability | MisdirectedRead, MisdirectedWrite |
PhantomWrite | A write reports success and never reaches the disk. | phantom_write_probability | PhantomWrite |
SyncFailure | sync_all, sync_data or sync_dir fails. | sync_failure_probability | SyncFailure |
ShortTransfer | A read or write transfers fewer bytes than asked. | short_transfer_probability | |
DirEntryLoss | A crash loses a directory entry that was not sync_dir’d. | unsynced_dir_entry_loss_probability | DirEntryLost |
CrashDamage | Unsynced sectors resolve by crash physics (below). | crash_lost_probability, crash_latent_fault_probability, shorn_write_probability | FileCrashReport |
Degradation | Disk stall (operations wait) or throttle (iops and bandwidth divided) episodes. | disk_stall_*, disk_throttle_* | |
DiskFailure | The disk fails for good. Every later operation stays pending forever. | disk_failure_probability | storage_disk_failure event |
DroppedMutationDiscard | A write, sync or set_len whose future was dropped is discarded instead of landing. Without it, every dropped mutation lands at its scheduled time. | dropped_mutation_discard_probability | |
WritebackError | After a write, the background writeback fails (fsyncgate). The dirty sectors become clean but not durable, so a retried sync succeeds and a crash can still lose them. writeback_error_reporting picks which handles see the error. | writeback_error_probability, writeback_error_revert_probability, writeback_error_reporting | WritebackError, storage_write_fault with write_kind = "writeback" |
The barrier-violation family (barrier_violation_probability) is separate from the mask. When it is 0, a sector a sync reported durable that changes across a crash fails the run with StorageFaultKind::LostSyncedWrite. This is the lost-synced-write oracle. When it is above 0, the lie is permitted and recorded.
StorageFaultMask::all(), none(), .with(fault), .without(fault), .contains(fault). StorageConfiguration::disable_fault_injection() zeroes every family including barrier violation.
The crash model
A crash (any RebootKind::Crash, CrashAndWipe, crash_self, or a disk crash during simulate_crash_for_process) resolves every sector written since the last sync independently:
CrashOutcome | Meaning |
|---|---|
KeptOld | The sector holds the bytes before the write. |
KeptNew | The write survived. |
Lost | The sector holds the fill pattern (garbage_fill_probability chooses garbage over zeros). |
LatentFault | The sector reads back with an error until rewritten. |
Shorn | Part of the sector is old, part new. |
clean_crash_probability makes some crashes lose nothing. correlated_rollback_probability and correlated_rollback_max_run roll back a run of adjacent sectors together. length_survives_crash_probability decides whether an unsynced set_len survives. FileCrashReport lists the outcome per sector; SimWorld::take_storage_crash_reports() drains them.
Targeted injection
| Method | On | Effect |
|---|---|---|
corrupt_file(path, sectors) | SimWorld | Corrupt sectors of a file now. |
fail_file_with_eio(path, sectors, EioTarget) | SimWorld | Make reads, writes or both fail on those sectors. |
clear_file_eio(path, EioTarget) | SimWorld | Lift it. |
corrupt_durable_out_of_band(path, sector) | SimWorld | Corrupt a synced sector without a crash (bit rot). |
corrupt_process_file_bytes(ip, path, bytes) | SimWorld | Owner-scoped byte range corruption. |
fail_process_file_with_eio(ip, path, sectors, EioTarget), clear_process_file_eio | SimWorld | Owner-scoped EIO. |
process_file_durable_bytes(ip, path) | SimWorld | The durable image of a file. |
durable_bytes(ip, path), corrupt_bytes(..), fail_file_with_eio(..), clear_file_eio(..) | FaultContext | The same from a custom injector. |
simulate_crash_for_process(ip, close_files) | SimWorld | Run crash resolution on a process’s files. |
wipe_storage_for_process(ip) | SimWorld | Delete every file the process owns. |
fail_disk_for_process(ip) | SimWorld | Permanent disk failure. is_disk_failed(ip) queries. |
set_disk_capacity(ip, Option<DiskCapacity>), disk_capacity(ip), disk_space(ip) | SimWorld, FaultContext | A disk size limit: growth past it fails with ENOSPC (StorageFaultKind::DiskFull, a storage_write_fault with write_kind = "full"). Recovery mode lifts it. |
set_storage_eligibility_mask(mask), clear_storage_eligibility_mask() | SimWorld | Gate the random families by (path, sector). |
set_fault_focus(focus), clear_fault_focus(), publish_layout(..) | SimWorld | Weight the random families by byte range or LayoutRegion. |
draw_replicated_faults(config, localities) | SimWorld | Draw a FaultPattern for a replicated group. |
take_storage_fault_records() | SimWorld | Drain StorageFaultRecord { path, kind, .. } entries. |
EioTarget is Read, Write or ReadWrite.
Replicated fault patterns
replicated_storage_faults(ReplicatedFaults::new(DomainLevel::Machine).tolerance(1).group("acceptors")) bounds damage so that a correct replication layer can always repair:
FaultPattern | Damage |
|---|---|
Minority { domains } | Only the listed domains, at most tolerance. |
Striped { domains, local } | Stripe s may be damaged in the s mod n-th domain; the local domains anywhere. |
Rolling { domains } | A window of tolerance domains at a time. The window moves once the damage is repaired. |
Spared | Fewer than two domains: nothing is damaged. |
The stripe key comes from LayoutRegion::stripe (the journal uses the entry position), so one pattern hits the same position on every replica.
Process Faults
| Fault | Source | What happens |
|---|---|---|
| Graceful reboot | Attrition (prob_graceful), FaultContext::reboot(ip, RebootKind::Graceful) | The shutdown token is cancelled. After the grace period the process is force killed, then restarted after the recovery delay. |
| Crash | prob_crash, RebootKind::Crash, FaultContext::crash(ip) | Tasks aborted, connections reset, storage through crash resolution. |
| Crash and wipe | prob_wipe, RebootKind::CrashAndWipe | Crash plus deletion of every owned file. |
| Timed crash | FaultContext::crash_for(ip, down) | Crash, restart after down. |
| Restart | FaultContext::restart(ip) | Restart a process that crash left down. |
| Random victim | FaultContext::reboot_random(kind), reboot_tagged(..) | One random eligible process. |
| Domain reboot | FaultContext::reboot_machine(..), reboot_domain(..); AttritionScope::{PerMachine, PerZone, PerDatacenter} | Every process of the domain at once. |
| Self crash | SimContext::crash_self(kind, Option<Duration>), SimContext::self_crash() -> SelfCrash | A process kills itself one scheduler tick from now, at an exact point of its protocol. No randomness is drawn. Only a process may call it. |
| Outage | Chaos::Outage | Every process of the named groups loses power at one instant. A workload can hold it with OutageVeto. |
| Hint | hint!("label") in library code | The seed’s first admitting attrition regime may reboot the process at that moment. A workload can budget it with HintVeto::new(|ip, label| bool).publish(state) under HINT_VETO_KEY. The veto also guards a per-process regime’s timed reboots, asked with TIMED_REBOOT_LABEL. |
Reboots are bounded by max_dead per injector pool. The restart runs the process factory again: a fresh instance, the same IP, the same disk unless wiped.
CPU and Gray Failures
| Fault | Source | What happens |
|---|---|---|
| CPU time | Chaos::Cpu(mode), SimWorld::set_cpu_model | Every poll, sent byte and written byte costs simulated time on the process’s one core. cpu_charge(Duration) adds explicit work. |
| Gray failure | Chaos::GrayFailure { config, mode } | Up to max_slow machines run slow for a long window: CPU, disk and network multipliers. |
| Manual slowness | FaultContext::set_slowness(ip, Slowness), SimWorld::set_slowness | Install multipliers by hand. SimContext::slowness(ip) reads them. |
Buggify Knob Spikes
Chaos::BuggifyKnobs calls apply_buggify_knobs() on the enabled network and storage profiles for some seeds, the way FoundationDB randomizes knobs under BUGGIFY. It has no effect on a surface that is off.
| Surface | Knob | Spiked range |
|---|---|---|
| Network | clog_rate | 2 to 5 per second (only when the seed has clogs) |
| Network | partition_rate | 0.5 to 2 per second (only when the seed has partitions) |
| Network | random_close_probability | 0.01 to 0.1 |
| Network | black_hole_probability | 0.01 to 0.1 |
| Network | bit_flip_probability (if already on) | 0.001 to 0.01 |
| Storage | iops | 100 to 5000 |
| Storage | bandwidth | 1 MB/s to 20 MB/s |
| Storage | sync_failure_probability | 0.05 to 0.2 |
| Storage | disk_stall_probability, disk_stall_duration | 0.1 to 0.5, 100 ms to 500 ms |
| Storage | disk_throttle_probability, disk_throttle_duration | 0.1 to 0.5, 1 s to 5 s |
| Storage | disk_failure_probability | 0.001 to 0.01 |
Fault Events in the Timeline
The engine records SimFaultEvents. The runner merges them into the trace under SIM_FAULT_EVENT_NAME ("sim_fault") with source = "sim" and a kind field. An invariant queries them with q.since("sim_fault", &cursor).
kind | Family |
|---|---|
process_graceful_shutdown, process_force_kill, process_restart | process lifecycle |
partition_created, partition_healed | directed partitions |
send_partition_created, send_partition_healed, recv_partition_created, recv_partition_healed | one-sided partitions |
interface_clogged | clogs |
random_close, black_hole, bit_flip | connection faults |
storage_read_fault, storage_write_fault, storage_sync_fault | storage I/O faults |
storage_disk_failure, storage_crash, storage_wipe | disk and crash |
SimWorld::take_faults() drains them in a low-level test.
Presets
| Preset | Use |
|---|---|
NetworkConfiguration::default() | FoundationDB’s always-on rates: rare random closes, clock drift, buggified delays, probabilistic connect failures. No clogs, partitions, bit flips or black holes. |
NetworkConfiguration::fast_local() | Microsecond latencies, chaos disabled. |
ChaosConfiguration::disabled() | Every network fault off. |
StorageConfiguration::default() | Realistic SSD timing, no faults. |
StorageConfiguration::fast_local() | 1 µs operations, no faults. |
random_for_seed() / swarm_for_seed() | What ChaosMode::Random and ChaosMode::Swarm draw. |
Glossary
Terms are listed alphabetically. Cross-references are in bold. Type and macro names are the ones in the current source.
Always assertion – An assertion that must hold on every evaluation (assert_always!, assert_always_or_unreachable!, the four assert_always_* numeric macros). A violation is recorded, never a panic, and fails the seed in the report.
Antithesis principle – Assertions never crash the program. Violations are recorded and reported, so the run continues and finds cascading failures. All 15 macros follow it.
Attrition – The built-in reboot regime (Attrition, Chaos::Attrition). Picks victims by scope and victims filter, keeps at most max_dead down, and chooses graceful, crash or crash-and-wipe reboots by weight. Also the regime that decides whether a hint may reboot.
Buggify – FoundationDB’s BUGGIFY model in moonpool-buggify. A site is active on half the seeds (buggify_init(0.5)); an active buggify!() fires 25% of the time. buggify_named! adds a label that set_activation controls. Decisions are seeded, so they replay.
Buggify knob – buggify_knob!(default, lo..hi) in moonpool-sim: a configuration value that takes a random value in range on some seeds. Chaos::BuggifyKnobs applies the same idea to the network and storage profiles.
Chaos surface – One entry of SimulationBuilder::enable_chaos: Chaos::Network, Storage, Attrition, Outage, BuggifyKnobs, Cpu or GrayFailure. Each carries a chaos mode.
Chaos mode – ChaosMode::Random (every family on, rates drawn per seed) or ChaosMode::Swarm (a per-seed subset of families). See swarm testing.
Chaos window – The chaos_duration during which injectors generate faults. At its end the runner enters recovery mode. The consequences of earlier faults remain.
CPU model – CpuModel { poll_cost, sent_byte_cost_ps, written_byte_cost_ps }. Under Chaos::Cpu every poll, sent byte and written byte costs simulated time on the process’s one core. cpu_charge(Duration) adds explicit work.
Crash model – At a crash, every sector written since the last sync resolves independently to a CrashOutcome: KeptOld, KeptNew, Lost, LatentFault or Shorn. The barrier is the sync. See lost-synced-write oracle.
Determinism – The same seed produces the same execution. Every draw goes through one counted RNG stream, and all I/O is simulated.
Determinism canary – SimulationBuilder::check_determinism(). Each seed runs twice; a diverging draw fails the seed with the "determinism canary" always-assertion and names the first diverging draw.
Discovery – A globally new interesting state seen by the assertion accounting: a first pass, a new bucket, a watermark, frontier or quality improvement. Latched by compare-and-swap, so it is journaled once across every timeline and seed. The explorer anchors continuations at discoveries.
Exemplar – A retained recipe plus the RNG call count of the discovery it reaches. The explorer keeps up to 3 per semantic state and rotates them.
Explorer – The frontier controller in moonpool-explorer. Owns the frontier, exemplars, statistics and the bounded worker pool. Talks to the simulation only through the assertion discovery hook and an RNG call-count function.
Fault focus – FaultFocus: weights per byte range (spot) or per LayoutRegion (layout) plus a background weight that steer the random storage families toward one structure. Installed with SimWorld::set_fault_focus.
Fault injector – A custom chaos driver: FaultInjector { name, inject(&FaultContext) }, built fresh per run by fault_factory. FaultContext gives reboots, partitions, byte corruption, slowness and the shared state handle.
Fault timeline – The "sim_fault" events (SIM_FAULT_EVENT_NAME) the runner merges into the trace timeline, with source = "sim" and a kind field.
Frontier (assertion) – For assert_sometimes_all!: the largest number of named facts true at once. Advancing it is a discovery.
Frontier (exploration) – The explorer’s FIFO queue of jobs, bounded by max_frontier.
Gray failure – A machine that stays alive and slow (GrayFailure, Chaos::GrayFailure). At most max_slow machines at once, each with CPU, disk and network multipliers for a window. Read with SimContext::slowness(ip).
Hint – hint!("label"): library code names a moment where a crash is interesting (entries written, not synced). A no-op in production. In simulation the runner may reboot the process there under the seed’s attrition regime.
HintVeto – HintVeto::new(|ip, label| bool).publish(ctx.state()): a workload’s budget on which hints may strike. Stored under HINT_VETO_KEY.
Invariant – A cross-process property checked after every simulation step. Reads the trace timeline through TraceQuery (since, snapshot, len) and reports with assert_always!. Registered with invariant or invariant_fn.
Iteration control – IterationControl::FixedCount(n) or UntilCoverageStable { plateau_seeds, max_iterations } (the default, 10 and 1000).
Journal – moonpool-journal: a CLSTORE write-ahead log over StorageProvider. Positions in any order, a crash told apart from corruption, damage named by position and Id, and Journal::layout / Journal::regions exposing LayoutRegions for aimed faults.
LayoutRegion – moonpool_core::LayoutRegion { path, bytes, kind, stripe, shard }: a named byte range of a file. Published with SimWorld::publish_layout, consumed by fault focus and replicated fault patterns.
Locality – The datacenter, zone and machine of a process (LocalityInfo), sampled from LocalityConfig by SimulationBuilder::cluster. Drives LinkLatencyConfig, partition strategies and attrition scopes.
Lost-synced-write oracle – A sector that a sync reported durable and that changes across a crash fails the run with StorageFaultKind::LostSyncedWrite, unless barrier_violation_probability arms that lie on purpose.
Multiverse – The logical tree of all timelines explored from one root seed. A data structure of recipes, never of live processes.
Outage – Outage, Chaos::Outage: every process of the named groups loses power at one instant, each back after its own delay, with an optional straggler. The landed outage is published as OutageLanded.
OutageVeto – OutageVeto::new(|victims| bool).publish(ctx.state()): a workload holds an outage until it permits it. Stored under OUTAGE_VETO_KEY; retried every VETO_RETRY.
Process – The system under test. A server node that boots, is killed and restarts through its factory with fresh memory. Its only persistence is simulated storage. Registered with processes or cluster. FoundationDB’s fdbd.
Process group – One processes or cluster registration. The g-th group lives on 10.0.{g+1}.x and is queried with ctx.topology().ips_in_group(name).
Provider – A trait over a runtime service: TimeProvider, TaskProvider, NetworkProvider, RandomProvider, StorageProvider, SystemProvider, bundled by Providers. Tokio implementations run in production, SimProviders in simulation. Same code, two worlds.
Recipe – The replay path to a timeline: Vec<(rng_call_count, seed)> breakpoints from the root seed. Printed as 151@8837201 -> 80@1293847. Replayed with replay_timeline.
Recovery mode – The state after the chaos window: SimWorld::enter_recovery_mode turns every configuration-driven fault family off and heals held partitions. buggify_fault_with_prob! and hints fall silent. The system must recover on its own.
Replicated fault pattern – ReplicatedFaults::new(level).tolerance(n).group(name) draws a FaultPattern (Minority, Striped, Rolling, Spared) that bounds storage damage to what a correct replication layer can repair.
Run time budget – run_time_budget, 1 simulated hour by default. A run past it is a stall.
Scheduler – The global Scheduler<Event> that owns monotonic simulated time, same-time FIFO order and cancellation.
Seed – A u64 that determines a simulation’s randomness. Pinned with set_debug_seeds.
SelfCrash – SimContext::self_crash() and crash_self(kind, delay): a process kills itself at an exact point of its own protocol, without a fault injector and without a draw.
Shard – moonpool_buggify::shard!(name, current = r1[, next = r2]): the code declares a shard’s Replication { replicas, progress, durable } (Need = AtLeast / All / Any). The run’s shard book (ctx.shards()) keeps every view not yet superseded, and the shard gate downgrades any destructive fault that would break a need: a kill becomes a clean restart, a wipe a crash, a crash a KillKeepCache, an outage spares victims, a partition or disk failure is not made, damage is not placed. ShardFaults adds aim, edge and a liveness core. Inert in production, like hint!.
Slowness – Slowness { cpu, disk, network } multipliers on one process, HEALTHY when all are 1. Set by gray failure or FaultContext::set_slowness.
Sometimes assertion – An assertion that must be true at least once across the campaign (assert_sometimes!, assert_reachable!, assert_sometimes_* numeric, assert_sometimes_all!). A miss is a coverage violation. Its passes are discoveries.
State handle – StateHandle from ctx.state(): a per-run shared map where workloads publish vetoes and injectors read workload state.
Storage fault mask / Network fault mask – StorageFaultMask, NetworkFaultMask: allow-masks applied after the per-seed draw, consuming no randomness. Used to remove a family a harness cannot observe (a sync lie, a silent disk failure).
Swarm testing – Per-seed subsets: ChaosMode::Swarm masks fault families, swarm_operations masks each workload’s operation alphabet, buggify_named! plus set_activation masks named sites. One workload, many behaviours.
Timeline – One complete simulation run: a root seed plus a recipe. The root timeline has an empty recipe.
Topology – WorkloadTopology from ctx.topology(): groups, IPs, tags, localities, peer_by_name, ips_tagged, ips_in_domain.
Trace timeline – The events SimulationLayer captures: INFO and above, constant message, emitted inside a process or workload span. Each TraceEvent has seq, time_ms, source, target, level, name and typed fields.
Watermark – For numeric assertions, the best value seen. For assert_sometimes_greater_than! the maximum, for less_than the minimum. An improvement on a sometimes watermark is a discovery.
Worker – A forked process that runs exactly one exploration job and exits. Writes its discovery journal into a MAP_SHARED slot. Live processes are bounded by 1 + workers; workers: 0 runs in-process.
Workload – The test driver. Survives process reboots, drives operations, keeps a reference model, checks results in check(). Registered with workload_factory or workloads. FoundationDB’s tester.actor.cpp.
Sim Compatibility Checklist
- 1. Time
- 2. Tasks and Concurrency
- 3. Network
- 4. Filesystem and Storage
- 5. System Metrics
- 6. Randomness
- 7. Collections and Iteration
- 8. Fault Sites and Probes
- 9. Type Bounds
- 10. External Processes and Syscalls
- 11. Observability
- 12. Assertions
Use this list when you bring an existing crate into a moonpool simulation. Determinism is the contract: every source of wall-clock time, real I/O, untracked concurrency or platform entropy goes through a provider or is replaced by a deterministic alternative. Each table pairs the calls that break replay with the moonpool equivalents. The axum example shows the whole pattern end to end.
1. Time
| Forbidden | Use instead |
|---|---|
tokio::time::sleep, tokio::time::timeout, tokio::time::Instant | time.sleep(d), time.timeout(d, fut), time.now() |
std::time::Instant::now, std::time::SystemTime::now | time.now() for canonical time, time.timer() for the node’s drifted clock |
TimeProvider is the one seam. In simulation sleep advances logical time. In production it falls through to tokio. Never measure elapsed time against a real clock.
2. Tasks and Concurrency
| Forbidden | Use instead |
|---|---|
std::thread::spawn, OS threads | task.spawn_task(name, fut) |
tokio::task::spawn_local, tokio::task::LocalSet | task.spawn_task(name, fut) (Send-bounded), or LocalTaskProvider::spawn_local_task(name, fut) for !Send code (CompioTaskProvider in production, SimTaskProvider in simulation) |
tokio::spawn | task.spawn_task(name, fut), or moonpool_sim::executor::spawn(name, fut) in sim-only code |
tokio::select! | moonpool_sim::select! or moonpool::select! (same grammar) |
There is no tokio runtime inside a simulation. Everything runs on the deterministic executor, so a bare tokio::spawn panics. Bare tokio::select! draws its branch offset from OS entropy. Moonpool’s select! keeps the grammar and draws the offset from the seed. Write guard-style selects (work versus shutdown) as select! { biased; .. } with the work branch first. Leave peer races in the default form so the seed explores both orders.
3. Network
| Forbidden | Use instead |
|---|---|
tokio::net::{TcpStream, TcpListener, UdpSocket} | network.connect(addr), network.bind(addr) |
std::net::* for live I/O | NetworkProvider |
tokio::net::lookup_host, ToSocketAddrs | a Resolver: TokioResolver in production, moonpool_sim::ScriptedResolver in simulation |
hyper_util::rt::TokioIo, tokio_util::compat | moonpool_hyper::HyperIo |
tonic::transport | tonic with default-features = false, ReconnectingChannel, H2Server |
NetworkProvider::TcpStream implements futures::io::AsyncRead + AsyncWrite. HyperIo presents it as hyper’s rt::Read and rt::Write. Opt into vectored writes with HyperIo::new(stream).with_vectored_writes(true) or the vectored_writes flag on ChannelConfig and H2ServerConfig; the simulated stream then delivers each IoSlice as its own event. HyperExecutor routes hyper’s h2 tasks to the TaskProvider, HyperTimer answers its clock reads from the TimeProvider. See The hyper Stack.
4. Filesystem and Storage
| Forbidden | Use instead |
|---|---|
tokio::fs::*, std::fs::* | storage.open(path, OpenOptions), exists, delete, rename, create_dir_all, list_dir, sync_dir |
Direct file handles, O_DIRECT by hand | StorageFile (read_at, write_at, write_at_durable, their zero-copy *_aligned forms over AlignedBuf, sync_all, sync_data, size, set_len, allocate), OpenOptions::direct_io(DirectIo::..), BlockFile<F> |
Storage operations return Poll::Pending and complete through scheduled events. A low-level test must step SimWorld while it polls (the drive helper in the root AGENTS.md). File durability is not directory-entry durability: sync_dir after a create or rename.
5. System Metrics
| Forbidden | Use instead |
|---|---|
getrusage, /proc/stat, /proc/diskstats, sysconf | system.sample(data_dir) -> SystemSample |
SystemProvider::sample returns process CPU, core count, machine busy and total time, run-loop busy time, optional DiskCounters and optional DiskSpace (statvfs). In simulation it reports the CPU model’s charges and the simulated disk, so an admission controller that watches its own load sees the chaos.
6. Randomness
| Forbidden | Use instead |
|---|---|
rand::thread_rng, rand::random, random UUIDs | random.random::<T>(), random.random_range(r), random.random_bool(p), random.random_ratio() |
OsRng, getrandom, /dev/urandom | RandomProvider |
One ungoverned thread_rng call makes a seed unreproducible.
7. Collections and Iteration
| Forbidden | Use instead |
|---|---|
HashMap, HashSet | BTreeMap, BTreeSet |
| Iterating an unordered collection from a dependency | Copy into a Vec and sort before acting |
HashMap randomizes iteration order per process. That is fatal under replay and forked exploration. The workspace clippy.toml disallows both types.
8. Fault Sites and Probes
| In library code | Purpose |
|---|---|
hint!("entries written, not synced") | Name a crash moment. A no-op in production. The simulator may reboot the process there. |
buggify!(), buggify_with_prob!(p), buggify_fault_with_prob!(p), buggify_named!("label", p) | Fire a rare path on seeded coins. Inert in production. |
cpu_charge(Duration) | Account CPU work the poll model cannot see (a checksum, a compaction). |
reachable!("msg"), sometimes!(cond, "msg") | Coverage probes from moonpool-assertions, without a moonpool-sim dependency. |
All of these come from moonpool-buggify and moonpool-assertions, which have zero dependencies, so a library can carry them without pulling in the simulator.
9. Type Bounds
- Futures that cross a trait are
Send + 'static. The executor is single-threaded, but spawned futures are Send-bounded. - Shared state:
Arc<RwLock<..>>,Arc<AtomicBool>,DashMap.Rc<RefCell<..>>only in code written against the local traits (LocalProviders,LocalTaskProvider) and spawned withspawn_local_task. Process,WorkloadandFaultInjectorare dyn-stored:#[async_trait]withSend + Sync + 'static.- Provider traits use native
async fnwith-> impl Future<..> + Send. No#[async_trait]. - Never hold a
MutexGuardorRwLockGuardacross.await. - No
unwrap(). For lock poison use.expect("RwLock poisoned: prior task panicked").
10. External Processes and Syscalls
| Forbidden | Use instead |
|---|---|
std::process::Command, tokio::process::Command | A trait with an in-memory fake |
Raw mmap, socket, direct libc calls | A provider or a trait you control |
| Native libraries that do their own I/O | Wrap them, or replace them with a deterministic fake |
When a dependency cannot be mediated, that call is the boundary of the simulation. Mock it at the highest level you control. See Where to Draw the Line.
11. Observability
| Forbidden | Use instead |
|---|---|
println!, eprintln!, dbg! for facts an invariant needs | tracing::info!(target: "raft", term, leader = %ip, "leader_elected") |
A custom log sink that bypasses tracing | A tracing layer; the runner installs SimulationLayer |
The layer captures INFO and above with a constant message, inside a process or workload task. Use % for strings. Do not add a time_ms field; the layer stamps simulated time.
12. Assertions
- Use
assert_always!,assert_sometimes!,assert_reachable!,assert_unreachable!and the numeric and compound variants. Full table in the Assertion Reference. - Standard
assert!andassert_eq!panic and abort the run. Prefer moonpool assertions for invariants that should be recorded and explored. - Never delete an assertion that catches a bug. Fix the bug.
Calibrating Against a Real Machine
- Moonpool Is Bypassed While Measuring
- Bounds Are p01 to p99, Not Min to Max
- The Command Surface
- Storage
- Network
- Plugging the Values In
- What This Tool Is Not
Every latency knob in the Configuration Reference ships with a hand-picked default: storage reads at 50 to 200 µs, cross-datacenter links at 20 to 80 ms. Those numbers are plausible. They are not your numbers. A simulated disk ten times faster than production never shows the timeout cascade production is one bad afternoon away from.
moonpool-calibrate closes that gap. It measures the machine it runs on and prints Rust source with LatencyDistribution constants for the configuration types moonpool already has.
real storage / network
↓ raw std I/O + std::time::Instant
measurement
↓ HDR histogram
p01 .. p99 envelope
↓ code generation
LatencyDistribution::Uniform { start, end }
↓ existing seed-driven sampling
deterministic simulated world
The calibrator decides plausible real-world bounds. Moonpool still decides the simulated realization: sample_latency draws from these distributions through the seeded simulation RNG, exactly as it does for the defaults. Determinism and replay do not change.
Moonpool Is Bypassed While Measuring
This is the architectural rule of the tool. A measurement taken through moonpool’s providers would measure the simulator, not the machine. So the measurement path touches no moonpool code:
| Concern | What the calibrator uses |
|---|---|
| Storage | std::fs::File, std::io::{Read, Write, Seek}, File::sync_all |
| Network | std::net::TcpListener, std::net::TcpStream |
| Time | std::time::Instant |
No provider trait, no simulated clock, no simulated randomness, no async runtime. Moonpool appears only in the generated output, and in the crate’s tests/generated_api.rs, which type-checks that output against the real API.
Bounds Are p01 to p99, Not Min to Max
The generated range is the 1st-to-99th percentile envelope of the samples. The raw extremes come from scheduler preemption, page faults and one-off kernel work. Feeding them into a simulation stretches the simulated world far past what the machine does on an ordinary day. Full diagnostics (p01, p50, p95, p99, max and the sample count) go to stderr, so you can see the tail you chose not to encode.
Samples go into an HDR histogram (1 ns to 60 s, three significant figures), not a growing vector, so the sample count costs nothing in memory.
The Command Surface
The crate is a workspace member and is not published. Run it with cargo run -p moonpool-calibrate -- <command>, or install the binary. The CLI is a clap derive: the commands are the Command and NetworkCommand enums, so the parser and the dispatch cannot drift apart.
One rule shapes the surface: stdout belongs to generated Rust. Help, version and parse errors go to stderr, so moonpool-calibrate --help > out.rs leaves out.rs empty instead of full of usage text.
Storage
Run this on the machine whose disk the simulation should imitate:
- Run
moonpool-calibrate storage > measured_storage.rs. - Read the percentile table on stderr.
- Add
measured_storage.rsto your crate.
| Flag | Default | Meaning |
|---|---|---|
--file PATH | $TMPDIR/moonpool-calibrate.scratch | Scratch file to measure against |
--samples N | 1000 | Recorded samples per operation (at least 1) |
--warmup N | 100 | Unrecorded warmup iterations per operation |
The method maps one-to-one onto moonpool’s three storage latency knobs:
- read: seek to a block,
read_exact4096 bytes, fold the bytes into a checksum handed tostd::hint::black_boxso the work cannot be optimized away. - write: seek to a block,
write_all4096 bytes. No sync. - sync: dirty one block untimed, then time
sync_allalone.
The 4 MiB scratch file (1024 blocks) is created and filled before timing starts, and removed by a drop guard, also on the error path. Each read and write targets a block chosen by a small deterministic PRNG, so the pattern is reproducible and not purely sequential.
A real run on a container filesystem, 5000 samples per operation:
operation p01 p50 p95 p99 max samples
read 282ns 484ns 1.159µs 1.704µs 31.583µs 5000
write 373ns 541ns 880ns 1.266µs 40.319µs 5000
sync 109.695µs 140.927µs 202.623µs 253.311µs 3.248127ms 5000
The max column sits far from p99. That gap is why the generated bounds stop at p99.
Network
The measuring side needs a peer.
- On host B, run
moonpool-calibrate network listen. - On host A, run
moonpool-calibrate network measure host-b:7777 > measured_network.rs. - Stop the listener with ctrl-c.
| Flag | Default | Meaning |
|---|---|---|
--port P (on listen) | 7777 | Port to bind on all interfaces |
--samples N (on measure) | 1000 | Recorded round trips |
--warmup N (on measure) | 100 | Unrecorded warmup round trips |
The protocol is the smallest thing that can be measured:
client server
|-- 8-byte sequence number ----->|
|<-- the same 8 bytes -----------|
RTT = elapsed
One connection carries every sample. TCP_NODELAY is on, so the measurement is not the delayed-ack timer. Each response is verified against the request that produced it. The listener serves one connection at a time, in accept order, because concurrency would only add scheduling noise.
Plugging the Values In
The generated file is ordinary Rust:
#![allow(unused)]
fn main() {
// Generated by moonpool-calibrate. Do not edit by hand.
// ...
use moonpool::LatencyDistribution;
use std::time::Duration;
/// Measured latency of a 4096-byte read.
///
/// p01 282ns, p50 484ns, p95 1.159µs, p99 1.704µs, max 31.583µs, n = 5000.
pub const STORAGE_READ_LATENCY: LatencyDistribution = LatencyDistribution::Uniform {
start: Duration::from_nanos(282),
end: Duration::from_nanos(1_704),
};
}
The storage command emits STORAGE_READ_LATENCY, STORAGE_WRITE_LATENCY and STORAGE_SYNC_LATENCY. The network command emits NETWORK_RTT_LATENCY and NETWORK_LATENCY. Assign them into the existing configuration types:
#![allow(unused)]
fn main() {
use moonpool::{LinkLatencyConfig, NetworkConfiguration, StorageConfiguration};
let storage = StorageConfiguration {
read_latency: measured_storage::STORAGE_READ_LATENCY,
write_latency: measured_storage::STORAGE_WRITE_LATENCY,
sync_latency: measured_storage::STORAGE_SYNC_LATENCY,
..Default::default()
};
let network = NetworkConfiguration {
write_latency: measured_network::NETWORK_LATENCY,
link_latency: Some(LinkLatencyConfig {
same_zone: measured_network::NETWORK_LATENCY,
..Default::default()
}),
..Default::default()
};
}
In a SimulationBuilder campaign, .link_latency(LinkLatencyConfig { .. }) takes the measured network value for the distance you measured. A hand-driven SimWorld takes both configurations through set_network_config and set_storage_config, and set_process_storage_config(ip, config) gives one process a measured disk. Measure each link class between the hosts that span it: a loopback run gives same_machine, two racks give same_datacenter, two regions give cross_datacenter.
RTT Versus One-Way
NETWORK_RTT_LATENCY is the round trip as measured. NETWORK_LATENCY is that round trip halved. Moonpool’s link knobs are documented as one-way delays, so NETWORK_LATENCY is the one that belongs in LinkLatencyConfig and write_latency. The RTT is kept for reference, because it is the number you can compare against ping.
What This Tool Is Not
- Not
fio. One scratch file, 4 KiB blocks, a deterministic block-pick pattern, and no attempt to defeat the page cache. NoO_DIRECT, no queue-depth exploration, no payload sweep, no bandwidth or IOPS measurement.iopsandbandwidthstay yours to set. - Not
iperf. Small-message TCP round-trip time only. Connection establishment is not timed, because moonpool has a separateconnect_latencyknob, and there is no bandwidth or packet-loss measurement. - Not a distribution fitter. The output is always
Uniform. A workload that needs theExponentialorBimodaltail shapes picks them by hand from the percentile diagnostics. - Not a new configuration mechanism. It emits values for the knobs that already exist, and those knobs are still sampled by the same seed-driven RNG.
Running Simulation Binaries with xtask
Simulation binaries are long campaigns that do not belong in cargo nextest. The xtask crate runs them with LLVM sanitizer coverage so that UntilCoverageStable judges saturation on real code edges. .cargo/config.toml defines the alias: xtask = "run --package xtask --".
Commands
| Command | Effect |
|---|---|
cargo xtask sim list [filter...] | Print the display names of the registered binaries, filtered by substring. |
cargo xtask sim run <filter...> [-- <args...>] | Build and run every binary whose name contains a filter. Arguments after -- go to the binary. |
cargo xtask sim run-all | Run every registered binary in order. |
A filter matches the binary name as a substring. cargo xtask sim run rpc runs all seven RPC campaigns. Display names drop the sim- prefix, so maze and sim-maze-explore both match. Each binary prints its own report; xtask prints --- name --- around it, the elapsed time, and a summary of how many passed and failed. The command exits non-zero when any binary fails.
Inside the Nix shell the command is nix develop --command cargo xtask sim run maze.
Registered Binaries
| Display name | Package | SANCOV_CRATES |
|---|---|---|
maze-explore | moonpool-sim-examples | moonpool_sim_examples |
dungeon-explore | moonpool-sim-examples | moonpool_sim_examples |
frontier-explore | moonpool-explorer | moonpool_explorer |
axum-web | moonpool-sim-examples | moonpool_sim_examples |
metrics-service | moonpool-sim-examples | moonpool_sim_examples |
topology | moonpool-sim-examples | moonpool_sim_examples |
tonic-grpc | moonpool-sim-examples | moonpool_sim_examples |
journal | moonpool-sim-examples | moonpool_sim_examples,moonpool_journal |
rpc-foundations, rpc-delivery, rpc-interfaces, rpc-balance, rpc-streams, rpc-security, rpc-qualification | moonpool-rpc-sim | moonpool_rpc,moonpool_rpc_sim |
The list is the SIM_BINARIES constant in crates/xtask/src/main.rs.
What xtask Runs
For each binary, xtask executes from the workspace root:
SANCOV_CRATES=<crates> cargo run --package <pkg> --bin <name> --target-dir target/sancov [-- <args>]
Two details matter:
- A separate target directory.
SANCOV_CRATESis not part of cargo’s fingerprint. Withouttarget/sancov, cargo would serve a cached, uninstrumented build. - The RUSTC_WRAPPER. The Nix shell exports
RUSTC_WRAPPER=scripts/sancov-rustc.sh. The wrapper readsSANCOV_CRATESand adds, for the listed crates only,-Cpasses=sancov-module -Cllvm-args=-sanitizer-coverage-level=3 -Cllvm-args=-sanitizer-coverage-inline-8bit-counters -Ccodegen-units=1. It never instruments build scripts or proc macros. WithSANCOV_CRATESunset it passes through to rustc unchanged.
Only the crates in SANCOV_CRATES get counters. Instrument the crate under test and the library whose branches the campaign exercises (moonpool_journal for the journal campaign), not the whole framework. More instrumented edges make the plateau slower to reach.
What Coverage Changes
With counters present, moonpool_explorer::sancov_is_available() is true and the runner uses SaturationSignal::CodeCoverage: edges covered per seed, cumulative, judged against plateau_seeds. Without counters it falls back to SaturationSignal::AssertionCoverage. The report shows which one it used, with edges_covered and edges_total in report.saturation. Under exploration, ExplorationReport carries sancov_edges_total and sancov_edges_covered, and code_coverage_edges merges every worker’s counters into one history map. Coverage is a plateau signal only. It never anchors an exploration recipe.
Without the wrapper (plain cargo run), the binary still runs. It only loses the code-coverage signal.
Adding a Binary
- Add a
[[bin]]namedsim-<name>to the package, with itsmaincallingSimulationBuilder::run()andreport.eprint(). - Exit non-zero when
report.is_failure()is true. - Add a
SimBinaryentry toSIM_BINARIESincrates/xtask/src/main.rs:SimBinary::example("sim-<name>")formoonpool-sim-examples,SimBinary::rpc(..)formoonpool-rpc-sim, orSimBinary::new(name, package, sancov_crates)otherwise. - Add the display name to the
simmatrix in.github/workflows/rust.yml. - Run
cargo xtask sim list <name>andcargo xtask sim run <name>once locally.
A binary that needs a flag (a seed, a mode) reads it from std::env::args() and gets it through cargo xtask sim run <name> -- <flag>.
In CI
The sim job runs each registered binary as one matrix entry with nix develop --command cargo xtask sim run <name>, with fail-fast: false, and uploads the log on failure. The macos job runs cargo xtask sim run frontier-explore as well, because the explorer’s fork, waitpid and MAP_SHARED usage and the Mach-O sancov sections must keep working on Darwin.