Skip to main content

Deterministic simulation testing for asyncio: seeded schedules, fault injection, perfect replay.

Project description

simloom

Deterministic simulation testing for Python's asyncio.
Find the race before it ships. Replay it forever from a seed.

PyPI Python versions CI License: Apache 2.0 Typed: strict


Your async code is tested one interleaving at a time — the polite one your laptop happened to schedule. Races ship. Flakes get retried. "Works on my machine" is the state of the art.

simloom runs your unmodified asyncio program inside a fully simulated world — a seeded scheduler that owns every interleaving, a virtual clock, an in-memory network with injectable latency, loss, partitions, and crashes — and explores thousands of hostile schedules looking for the one that breaks your invariants. When it finds one, it hands you a seed that replays the failure byte-for-byte, forever, and shrinks it to the minimal schedule that still triggers the bug.

FAILED test_lease_exclusivity — simloom found a failing universe
  seed: 17   (re-run: pytest -k lease --simloom-seed=17)
  error: AssertionError: two holders of an exclusive lease
  shrunk: 31 draws → 29, schedule deviations 25 → 1 (106 candidate runs)
  minimal schedule: FIFO everywhere except:
    draw #0: sched.pick = 1 (of 4)
  artifacts: .sim/failures/test_lease_exclusivity-seed17.tape.json, …

The entire bug, above, is "one callback ran out of order, once." No more staring at a flake that reproduces every thousandth CI run.

Install

pip install simloom        # or: uv add simloom

Python 3.12+. Zero runtime dependencies. The pytest plugin loads automatically.

Quickstart

Write an ordinary async test, decorate it, and let simloom explore the schedule space:

import asyncio
import simloom

@simloom.test(runs=2000)          # explores 2000 schedules; pytest collects this
async def test_counter_is_atomic():
    state = {"value": 0}

    async def worker():
        for _ in range(3):
            current = state["value"]
            await asyncio.sleep(0)          # a scheduling point — the race lives here
            state["value"] = current + 1

    await asyncio.gather(*(worker() for _ in range(3)))
    assert state["value"] == 9             # a plain assert: it fires under exploration
pytest                                  # finds the lost-update race, shrinks it, prints the seed
pytest --simloom-seed=42                # replay one exact universe

Need a distributed system? Ask for a world and you get hosts, a network, and faults:

@simloom.test(runs=5000)
async def test_leader_election(world):
    nodes = [world.host(f"n{i}") for i in range(5)]
    for h in nodes:
        h.spawn(lambda h=h: run_node(h, peers=nodes))   # your real, unmodified asyncio code

    world.net.partition(nodes[:2], nodes[2:])           # faults are first-class
    await world.sleep(30)                               # virtual seconds — wall time ≈ 0
    world.net.heal()
    nodes[0].crash()                                    # a real power cut: no finally blocks
    nodes[0].restart()                                  # comes back against fsynced disk only

    await world.until(lambda: exactly_one_leader(nodes), timeout=120)

Why this didn't exist before

Rust has loom, turmoil, madsim, and shuttle. .NET had Coyote. FoundationDB built a company-defining simulator; Antithesis sells the methodology at the hypervisor level. Python — where a huge share of backend glue and agent orchestration is written — had nothing.

And asyncio is structurally perfect for it: every interleaving decision happens at an await, under a replaceable event loop. simloom swaps in a deterministic one — no forked interpreter, no hypervisor, no recompilation. Because the ecosystem (aiohttp, httpx, the streams API) bottoms out in loop primitives, real, unmodified libraries run inside the simulation. Our CI runs a genuine aiohttp server against a genuine httpx client over the simulated network, with 20% packet loss injected, replayable from a seed.

What you get

  • 🎲 Seeded, exhaustive-ish scheduling — a single choice tape (the Hypothesis trick, applied to schedules) drives every decision. One seed → one exact universe.
  • ⏱ Virtual time — an hour of simulated asyncio.sleep traffic runs in milliseconds. Timeouts and retries are tested at full speed.
  • 🌐 A simulated network — in-memory transports with tape-driven latency, loss (modeled as TCP retransmit delay — streams never corrupt), partitions, asymmetric blocks, and connection resets.
  • 💥 Honest crasheshost.crash() is a power cut: tasks stop with no finally blocks, unsynced disk writes are lost or torn, peers see resets. restart() brings the host back against its surviving fsynced state.
  • 🔬 Fault injection in your codesimloom.sometimes("drop_cache") is tape-driven inside the sim and a constant False in production. Annotate rare branches; explore them.
  • 🪓 Automatic shrinking — failures reduce to the minimal schedule deviation, with a human-readable explanation and a replayable artifact on disk.
  • 🧭 Pluggable search — a uniform random walk and PCT (Probabilistic Concurrency Testing), which finds deep ordering and starvation bugs a random walk essentially never hits.
  • 🚨 Escape detection — touch a real socket, signal, subprocess, or the wall clock from inside the sim and you get an EscapedSimulationError at the exact call site instead of silent nondeterminism.

It finds real bugs

A multi-year CPython race. Pre-3.12 asyncio.wait_for could swallow a delivered cancellation when the inner future completed in the same window as the cancel (bpo-42130). In production it took an exact wall-clock collision; under simloom the timeout boundary is just another scheduling choice, so exploration finds the interleaving from a seed and replays it exactly. The modern implementation survives the identical torture. → examples/bpo42130.py

The canonical demo. A toy Raft over the simulated network — persisted term/votedFor, JSON-RPC, the works — tortured with partitions, crashes, and restarts. Plant the classic double-vote bug and exploration elects two leaders in one term in ~1 seed of 5; the fixed version survives, with coverage counters proving the faults actually fired. → examples/toy_raft.py

Honesty first

A determinism claim is only as good as its disclosed limits. simloom raises a loud error when your code reaches outside the simulation, and docs/determinism.md states exactly what is and isn't deterministic. Known boundaries: blocking C-extension I/O (psycopg2, requests, grpc's C core) can't run in-sim; direct time.time() reads bypass the virtual clock; real subprocesses and external servers need Python stand-ins. None of these fail silently.

Status

Pre-alpha, and built in the open. The deterministic core, simulated world, fault matrix, explorer, shrinker, and pytest plugin all exist and are exercised by a 10,000-seed determinism torture on every CI run (the harness holds itself to the same hostility it applies to your code). The API may still shift before 1.0. If you try it, open an issue — early feedback shapes it.

Learn more

Development

uv run --all-extras pytest          # tests (incl. the determinism torture)
uv run --all-extras mypy src        # strict typing
uv run --all-extras ruff check .    # lint

License

Apache-2.0. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

simloom-0.1.0.tar.gz (44.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

simloom-0.1.0-py3-none-any.whl (50.3 kB view details)

Uploaded Python 3

File details

Details for the file simloom-0.1.0.tar.gz.

File metadata

  • Download URL: simloom-0.1.0.tar.gz
  • Upload date:
  • Size: 44.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for simloom-0.1.0.tar.gz
Algorithm Hash digest
SHA256 6c49c9ba6c026881fefbc350546380bee0069c509c188e2c208cc8dede3e269f
MD5 205fa451601f34fb9ac40e70637f786b
BLAKE2b-256 0b140e50ab2acbcd19f1705bad6ddafb8e9402466dca3e25eddbb5fc5d396bf7

See more details on using hashes here.

Provenance

The following attestation bundles were made for simloom-0.1.0.tar.gz:

Publisher: release.yml on mandipadk/simloom

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file simloom-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: simloom-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 50.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for simloom-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 035a763420dd34b9f32822a372a7c3ffc135179b5b9befe6a7ec8e6401caa313
MD5 e8a3b770118e4bf0d78154fe0422b949
BLAKE2b-256 ac658f7e2b162560e1495edb8f30ffb9734a0ff2e33871e85cf111805e0fa181

See more details on using hashes here.

Provenance

The following attestation bundles were made for simloom-0.1.0-py3-none-any.whl:

Publisher: release.yml on mandipadk/simloom

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page