Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Using moonpool-sim Standalone

moonpool-sim is a standalone simulation engine. Provider traits, chaos injection, assertions, and fork-based exploration work with your existing application protocol. That may be raw TCP, axum behind hyper, tonic gRPC, or a service whose database boundary is replaced with an in-memory fake.

Why does this matter? Most teams are not designing a new networking stack. They are running services behind a load balancer, talking to databases, and shipping features. Moonpool controls the runtime boundaries while their application code stays recognizable.

The Technical Foundation

The key fact that makes this possible lives in the NetworkProvider trait:

#![allow(unused)]
fn main() {
pub trait NetworkProvider: Clone + Send + Sync + 'static {
    type TcpStream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
    // ...
}
}

SimTcpStream implements futures::io::AsyncRead + AsyncWrite + Unpin (the runtime-agnostic IO traits) and is Send + Sync + 'static. One thin adapter turns that into whatever shape a library wants, which makes it a drop-in replacement for tokio::net::TcpStream anywhere the ecosystem uses trait-based I/O. And the tokio ecosystem uses trait-based I/O everywhere that matters: hyper, tonic, tower, axum (via hyper), sqlx’s wire protocol, redis-rs.

This isn’t an accident. We designed the provider traits to match tokio’s interfaces exactly because we wanted existing libraries to work unchanged.

Proof: Real HTTP Over Simulated TCP

The hyper integration test in crates/moonpool-sim/tests/hyper_http.rs demonstrates this concretely. Unmodified hyper HTTP/1.1 running over simulated TCP with chaos injection:

#![allow(unused)]
fn main() {
struct HyperServer;

#[async_trait]
impl Process for HyperServer {
    fn name(&self) -> &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 work, not a peer racing it.
        let (stream, _addr) = moonpool_sim::select! {
            biased;
            result = listener.accept() => result?,
            _ = ctx.shutdown().cancelled() => return Ok(()),
        };

        // SimTcpStream implements the futures-io traits; HyperIo presents
        // them as the IO traits hyper defines for itself.
        let io = HyperIo::new(stream);

        hyper::server::conn::http1::Builder::new()
            .serve_connection(io, service_fn(handle_request))
            .await?;

        Ok(())
    }
}
}

Hyper 1.x defines its own rt::Read and rt::Write traits, and moonpool_hyper::HyperIo implements them directly over the futures-io shape every provider stream already has. From hyper’s perspective nothing has changed: HTTP parser, chunked encoding, keep-alive logic, content-length validation, all exercised for real over simulated networking.

The client side follows the same pattern. Connect via ctx.network().connect(), wrap in HyperIo, hand to hyper::client::conn::http1::handshake. Real HTTP/1.1 request-response cycles over a network that drops packets, injects latency, and kills connections.

HTTP/2 asks more of the runtime than this, because it spawns work and reads a clock. The hyper Stack covers what that takes, and what moonpool-hyper hands you for gRPC.

Spawning Inside a Simulation

The sim is single-threaded by construction. Every iteration runs on the moonpool deterministic executor, so every spawned task runs on the one OS thread that drives simulation events. Determinism depends on it.

What changed compared to earlier moonpool versions: the types crossing trait boundaries are now Send + 'static. Provider traits, SimTcpStream, Process, Workload, the SimContext you get handed, all of them. Spawning goes through the executor, either directly with moonpool_sim::executor::spawn (named tasks, the name shows up when debugging a seed) or through the provider seam, and customer state can use Arc<RwLock<…>>, DashMap, Arc<AtomicBool> without ceremony.

#![allow(unused)]
fn main() {
// Works: SimTcpStream is Send, the future is Send, the executor accepts it.
let handle = moonpool_sim::executor::spawn("serve-one", async move {
    serve_one(stream).await
});
}

If you want to keep the provider seam (so the same code runs against a real tokio runtime later), reach for TaskProvider::spawn_task from ctx.task() instead. Same semantics, an injectable interface, and inside the sim it lands on the same executor.

Do not use tokio::task::spawn_local or build a LocalSet. There is no tokio runtime inside a simulation, so there is no LocalSet for spawn_local to attach to and the spawn would never poll. Whenever you reach for a !Send workaround, you have probably wandered off the supported path.

The one case that still bites people: some third-party connection futures (hyper’s Connection is the canonical example) are themselves !Send for reasons unrelated to moonpool. They hold internal state that isn’t Send. You cannot spawn those on any Send-bounded executor, moonpool’s included. The fix is to drive them inline alongside your other work:

#![allow(unused)]
fn main() {
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;

// hyper's connection future is !Send by its own design.
// Drive it inline with select! instead of spawning.
let driver = async move {
    let _ = conn.await;
};

moonpool_sim::select! {
    biased;
    result = send_requests(&mut sender) => result?,
    _ = driver => {}
    _ = ctx.shutdown().cancelled() => {}
}
}

That is a hyper API decision, not a moonpool constraint. Axum handlers, tonic services, tower middleware, all Send. Spawn them freely.

What This Means For You

If your application uses tokio::net::TcpStream through trait-based I/O (which hyper, axum, tonic, and most of the ecosystem do), you can simulate it. The process is:

  1. Define a Process that binds a listener and serves connections
  2. Define a Workload that connects and sends requests
  3. Wire them together with SimulationBuilder
  4. Run thousands of iterations with chaos injection

No actor system required. No RPC framework. No new programming model. Just your existing HTTP handlers running over a network that tries to break them.