Skip to content

hinanohart/seedloop

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

seedloop

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.

Why

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.

Install

# from source (a PyPI pre-release is planned):
pip install "git+https://github.com/hinanohart/seedloop"

Quickstart

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 run

In 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 seed

coro_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 run

What it finds (measured)

Running 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.

Scope & limitations (v0.1)

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.

How it works

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.

License

Apache-2.0. See LICENSE.

About

Deterministic-simulation-testing for Python asyncio: a seeded scheduler that reproduces async races byte-for-byte from a single seed.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages