Deterministic-simulation-testing for Python asyncio. A seeded event loop that
makes async task interleaving and time deterministic, so a flaky race reproduces
byte-for-byte from a single integer seed — the way madsim/turmoil do for Rust
and Coyote does for C#, but for the asyncio runtime that today has no such tool.
Status: pre-alpha (v0.1.0a2). API may change. See Scope & limitations below.
asyncio guarantees only deterministic startup order; completion order, timers, and
randomness are uncontrolled, so concurrency bugs surface as CI flakes you cannot
reproduce. seedloop takes control of the scheduler with a seeded PRNG and runs a
virtual clock, so:
- the same seed always produces the same interleaving (reproduce a flake on demand);
- different seeds explore different interleavings (find the race in the first place);
await asyncio.sleep(3600)returns instantly (virtual time), so timeout logic is testable.
# from source (a PyPI pre-release is planned):
pip install "git+https://github.com/hinanohart/seedloop"import asyncio
import seedloop
async def scenario():
state = {"ready": False, "data": None}
async def producer():
state["data"] = 42
await asyncio.sleep(0)
state["ready"] = True
async def consumer():
await asyncio.sleep(0)
assert state["ready"], "consumer observed data before producer was ready"
await asyncio.gather(producer(), consumer())
# Search the seed space for an interleaving that breaks the assertion:
failure = seedloop.find_failing_seed(scenario, range(1000))
if failure:
print(f"race found at seed {failure.seed}: {failure.exception}")
seedloop.run(scenario, seed=failure.seed) # reproduces the SAME failure, every runIn pytest, the @simulate decorator runs a test under many seeds and names the failing one:
from seedloop import simulate
@simulate(seeds=range(1000))
async def test_no_use_before_ready():
... # your assertions; failure message includes the reproducing seedcoro_factory is a zero-argument callable returning a fresh coroutine (so the same
logic can be re-run under each seed).
When a race corrupts a return value rather than raising, search by predicate with
find_seed_where, which returns the first (seed, result) your predicate accepts:
bad = seedloop.find_seed_where(scenario, lambda result: result == "MISSED", range(1000))
if bad:
seed, result = bad # this seed reproduces `result` on every runRunning examples/demo.py on the "use before ready" race above
(python examples/demo.py — reproducible on any machine, every number computed at runtime):
| outcome | |
|---|---|
| default asyncio (50 runs) | {42: 50} — always "correct"; the race never shows |
| seedloop (1000 seeds) | {42: 492, MISSED: 508} — the race surfaces in 508/1000 seeds (50.8%) |
| reproduction | seed 4 reproduces MISSED in 100/100 runs (deterministic) |
(Counts above are representative output from one run of examples/demo.py; the script
recomputes them every time, and the exact failing seed may differ across CPython builds.)
The default loop happens to always pick the lucky interleaving, so the bug is invisible in ordinary testing. seedloop explores the schedule space, surfaces the failing interleavings, and hands you a seed that reproduces one every time.
seedloop controls single-threaded, pure-coroutine asyncio code. Out of scope for
v0.1 (these behave as on the stock loop and are not made deterministic): real sockets
and file I/O, threads and run_in_executor, subprocesses, and signals. Two timers with the same deadline run in the loop's heap (scheduling) order within a
step — the per-step shuffle does not reorder them directly — but their effective order
still varies with the seed whenever the coroutines scheduling them are interleaved, so
timer-ordering races are still explored in realistic code. Virtual time is a float in
seconds, so once total elapsed virtual time grows past ~2**24 s (~194 days) it loses
sub-second resolution; at extreme magnitudes (≳10**15 s) a further sub-second sleep can
no longer be represented at all, so such mixes are unsupported (realistic test horizons
are far below this). seedloop also depends on CPython-internal event-loop details (_ready,
_scheduled, _run_once) and may need updating for future CPython releases. Targets CPython
3.10–3.13 on POSIX/macOS; Windows is untested.
SeedLoop subclasses asyncio.SelectorEventLoop and, at each loop step, permutes the
batch of ready callbacks with a seeded random.Random, and advances a virtual clock to
the next scheduled timer instead of sleeping. No CPython fork required.
Apache-2.0. See LICENSE.