Quickstart¶
From nothing to a replayed failure and a minimized schedule. Every command and every file on this page is meant to be pasted in order; the outputs are what they actually printed.
Install¶
Python 3.12+, no runtime dependencies. The pytest plugin ships inside the package and registers itself, so there is nothing to enable and nothing to add to a config file.
Your first sim test¶
@sim_test turns an async def test into an ordinary synchronous test that
pytest collects. There is no fixture, no asyncio_mode, and no other asyncio
plugin involved — simloop brings its own loop.
Write tests/test_ledger.py:
import asyncio
from simloop import sim_test
@sim_test
async def test_virtual_time_is_free():
loop = asyncio.get_running_loop()
start = loop.time()
await asyncio.sleep(300)
assert loop.time() - start == 300
tests/test_ledger.py . [100%]
simloop: 1 sim test, 10 seeds explored
============================== 1 passed in 0.15s ===============================
Two things happened there. Five simulated minutes passed in no measurable
wall-clock time, because the clock inside a SimLoop only moves when nothing
is left to run. And the test ran ten times, not once: a bare @sim_test
explores seeds 0 through 9, each on a fresh loop with a fresh schedule. The
summary line at the end of every run says how many.
Give it something to race¶
A deposit and the audit that is supposed to see it, with three background tasks chattering alongside so the ready queue has choices to make. Add it to the same file:
@sim_test(seeds=50)
async def test_the_audit_sees_every_deposit():
loop = asyncio.get_running_loop()
ledger = {"balance": 0}
audited = []
def deposit():
ledger["balance"] += 1
def audit():
audited.append(ledger["balance"])
async def chatter():
for _ in range(20):
await asyncio.sleep(0.001)
background = [loop.create_task(chatter()) for _ in range(3)]
await asyncio.sleep(0.005)
loop.call_soon(deposit)
loop.call_soon(audit)
await asyncio.gather(*background)
assert audited == [1]
seeds=50 asks for fifty schedules instead of ten. Run the file again:
E assert [0] == [1]
E simloop: failed at seed 2 (2 seeds passed first)
E replay: pytest 'tests/test_ledger.py::test_the_audit_sees_every_deposit' --simloop-replay=2
E
E last 20 trace events:
E [t=0.0200] run seq=123 driver _set_result_unless_cancelled
E [t=0.0200] schedule seq=129 driver Task.task_wakeup
E ...
E
E runs agree for 15 events; passing then ran _set_result_unless_cancelled, failing ran Task.task_wakeup
Seeds 0 and 1 passed; seed 2 ran the audit before the deposit it should have seen. The report is the whole diagnosis: the failing seed, the command that replays it, the tail of the trace, and a diff against the last passing seed saying how long the two runs agreed and what each did first at the point they stopped agreeing.
Searching wider is a command-line option, not a code change:
--simloop-seeds overrides whatever the decorator asked for, which is how a
suite runs ten seeds on a laptop and a thousand in CI from the same source.
Replay the failing seed¶
Paste the replay line the report printed:
E assert [0] == [1]
E simloop: failed at seed 2 (0 seeds passed first)
simloop: 1 sim test, 1 seeds explored
1 failed in 0.13s
One seed, one run, the same failure. Not "usually the same failure": same
scheduling decisions, same fault decisions, same trace, and the trace hash to
prove it. That is what makes a --simloop-replay=2 line worth pasting into a
bug report, and it is the point of the whole exercise — a concurrency bug you
can reproduce on demand is a concurrency bug you can debug.
Shrink the schedule to the race¶
A failing schedule is mostly noise: hundreds of decisions, of which a handful
mattered. --simloop-shrink replays edited copies of the recorded schedule,
walking it back toward plain FIFO order and keeping only the decisions that
still reproduce the failure:
E simloop: failed at seed 2 (2 seeds passed first)
E schedule shrink (experimental): 137 steps recorded, 14 runs to minimize
E minimized: FIFO except step 36
E step 36 test_the_audit_sees_every_deposit.<locals>.audit
One step out of 137 had to go a particular way, and it is named: the audit.
When the answer comes back minimized: FIFO throughout instead, that is also
an answer — the interleaving never mattered, so look at the fault timings
rather than the task order.
Shrinking is off by default and marked experimental. It costs extra runs of
the workload, capped by --simloop-shrink-budget (default 500).
Draw the run¶
A schedule reads better as a picture than as a wall of trace lines:
Every failing seed leaves simloop-timeline-seed<N>.html, named in its own
report. The page is self-contained — inline CSS, script and SVG, nothing
fetched — so it opens from a CI artifact store as readily as from disk. It
draws one lane per simulated machine and one for the simulation itself,
virtual time running left to right, a dot for every scheduling decision and an
arrow for every packet that crossed.
Where to go next¶
- Nothing above touched the network.
loop.netis where the simulated one lives:set_defaults(latency=..., drop=...),partitionandheal,crashandrestart, per-host disks that survive a crash, and clocks that disagree. The front page has a networked example and the supported API has the full contract. - Property-based testing composes with this without an integration package: Hypothesis searches the data, simloop searches the schedule. The recipe, and the reasons a seed must not be a strategy, are in the cookbook.
- Before pointing simloop at a real library, read compatibility — what aiohttp, anyio, websockets and httpx do under simulation is measured there, not promised.
- Code that bypasses the event loop raises
SimulationFenceErrorrather than quietly breaking determinism. Which calls those are, and why the line falls where it does, is in supported API and design.