Deterministic thread-interleaving fuzzer: loom-style concurrency testing for free-threaded Python
Project description
interleave-test
Deterministic thread-interleaving testing for Python — find the race before free-threading finds it for you.
Free-threaded Python (PEP 703, shipping in 3.13/3.14) removes the GIL, and
with it the accidental serialization that kept most pure-Python race
conditions asleep. The races were always there — x += 1 has been three
bytecodes all along — but your tests ran them one thread switch per 5 ms and
never saw the bad ordering. Running tests "in parallel and hoping"
(the pytest-run-parallel approach) samples a handful of schedules out of
millions. Rust solved this problem with
loom: take control of the scheduler and
search the interleaving space. Python has had nothing comparable.
interleave-test is that tool:
- Controlled scheduler — your model's threads are serialized and every bytecode instruction (or line) plus every synchronization operation is a switch point the tool chooses, not the OS.
- Search strategies — uniform random, PCT (probabilistic concurrency
testing, far better at rare orderings), and bounded-exhaustive DFS in the
style of Microsoft's CHESS (
max_preemptions). - Deterministic replay — a failure is not a flake; it's a
Scheduleyou can serialize to JSON, attach to a bug report, and re-run instruction for instruction (on the same Python version — schedules count bytecode instructions). - Deadlock detection — the scheduler always knows what every thread is blocked on; ABBA deadlocks come back as a table, not a hung CI job.
- Modelled primitives —
threading.Lock/RLock/Condition/Event/Semaphore/ BoundedSemaphore/Thread,time.sleep/monotonic/time, andqueue.SimpleQueueare patched inside the run;queue.Queueworks because it builds on those. Blocked threads never block an OS thread, and timed waits run on a deterministic virtual clock. - Works on GIL and free-threaded builds — the scheduler serializes execution itself, so results are identical on 3.13t/3.14t and normal builds. A race found here is a real race under free-threading (and, at bytecode boundaries, under the GIL too).
Requires Python ≥ 3.12 (built on sys.monitoring, PEP 669). Pure Python, no
dependencies.
pip install interleave-test
(Not yet on PyPI? Install from a source checkout with pip install ..)
Sixty seconds
import interleave_test as it
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1 # LOAD / ADD / STORE — three steps, no lock
def model():
counter = Counter()
t1 = it.spawn(counter.increment)
t2 = it.spawn(counter.increment)
t1.join(); t2.join()
assert counter.value == 2
it.explore(model, iterations=500, seed=42)
interleave_test._errors.RaceFound: model raised AssertionError under an explored interleaving
found on schedule 6 of this run (seed=42), 65 scheduling decisions
raised in T0(model)
Traceback (most recent call last):
...
File "examples/counter_race.py", line 23, in model
assert counter.value == 2
AssertionError
last 25 of 65 scheduling decisions (thread granted, where it resumed):
#40 T2(increment) counter_race.py:14
#41 T2(increment) counter_race.py:14
#42 T0(model) counter_race.py:21
...
reproduce exactly: interleave_test.replay(model, err.failure.schedule)
rerun the search: interleave_test.explore(model, seed=42, ...)
Both threads read 0, both wrote 1. Schedule 6, a few milliseconds in.
The same model with a lock around the increment passes every schedule —
which is now a checkable claim, not a hope:
def model():
counter = Counter()
lock = it.Lock() # or threading.Lock(): patched inside the run
def safe_increment():
with lock:
counter.increment()
t1 = it.spawn(safe_increment)
t2 = it.spawn(safe_increment)
t1.join(); t2.join()
assert counter.value == 2
result = it.explore(model, iterations=500, seed=42)
assert result.ok
Writing models
A model is a zero-argument callable that builds its own state, starts threads, joins them, and asserts. It is run once per schedule, so it must be deterministic apart from scheduling: no wall-clock reads, no unseeded randomness, no I/O. (That determinism is what makes replay exact.)
Inside a model you can use:
it.spawn(fn, *args, name=..., **kwargs)→Taskwith.join(),.result(),.is_alive()threading.Thread(target=...)— patched to a controlled threadit.Lock/RLock/Condition/Event/Semaphore/BoundedSemaphore— or thethreading.names, which are patched inside the runqueue.Queue,LifoQueue,PriorityQueue— they build on patchedthreadinginternals — andqueue.SimpleQueue, which is patched to the pure-Python implementation (the C one blocks invisibly)time.sleep(n)— patched to a pure scheduling point (returns instantly; sleeping is meaningless when the schedule is explicit);time.monotonicandtime.timeserve the scheduler's virtual clock, soq.get(timeout=5)expires logically instead of spinning for 5 real secondsit.no_interleave()— a context manager that makes a region atomic, for setup code or for asserting "this region is the racy one"
As a pytest test, use the decorator:
import interleave_test as it
@it.interleave(iterations=500, seed=0)
def test_counter_is_thread_safe():
counter = Counter()
lock = it.Lock()
def safe_increment():
with lock:
counter.increment()
tasks = [it.spawn(safe_increment) for _ in range(2)]
for t in tasks:
t.join()
assert counter.value == 2
The decorated function is the exploration; bare pytest runs it (drop the
lock and pytest reports the RaceFound). Models take no parameters —
fixtures aren't supported; use closure state. The undecorated model stays
available as test_counter_is_thread_safe.model, and the last run's
Result as test_counter_is_thread_safe.last_result.
Strategies
| strategy | what it does | when |
|---|---|---|
"random" (default) |
uniform choice among runnable threads at every decision | fast smoke-fuzzing |
"pct" |
random thread priorities + depth-1 demotion points (Burckhardt et al.) |
rare orderings; probabilistic guarantees |
"dfs" |
bounded-exhaustive enumeration, ≤ max_preemptions preemptions (CHESS-style) |
small models; proof-shaped confidence |
it.explore(model, strategy="dfs", max_preemptions=2, iterations=100_000)
DFS sets result.exhausted = True when it fully explored the bounded
space — for a small model that is a theorem about every schedule with at
most N preemptions, at the granularity you chose. iterations caps the
number of schedules for every strategy. Custom strategies subclass
it.Strategy and implement pick(runnable, current, index).
Failures, seeds, replay
Every failure raises RaceFound, DeadlockError, or HangError
(subclasses of InterleaveError) carrying:
err.failure.schedule— the exactSchedule(JSON-serializable:schedule.to_json()/Schedule.from_json(text))err.failure.steps— the last scheduling decisions, human-readableerr.failure.seed,err.result— enough to rerun the whole search
try:
it.explore(model, iterations=1000)
except it.RaceFound as err:
it.replay(model, err.failure.schedule) # same crash, every time
Deadlocks come back explained — the failure includes a blocked-threads table:
deadlock: every live thread is blocked
found on schedule 50 of this run (seed=3586340675), 79 scheduling decisions
T0(model) blocked on join(T1(one)) at deadlock.py:25
T1(one) blocked on Lock held by T2(two) at deadlock.py:15
T2(two) blocked on Lock held by T1(one) at deadlock.py:20
...
How it works
explore() runs your model repeatedly. Each run, a cooperative scheduler
serializes all controlled threads: exactly one runs at a time, and at every
scheduling point — each bytecode instruction of your code (via
sys.monitoring, PEP 669), plus every modelled synchronization operation —
the strategy picks who runs next. Handoff is direct thread-to-thread; a
logically blocked thread parks inside the scheduler, never on an OS
primitive, so the runnable set is always exact. That yields deadlock
detection for free, and it makes an execution a pure function of the
decision sequence — hence replay.
sys.monitoring rather than sys.settrace is a correctness decision, not a
style one: the legacy trace hook applies per-code-object instrumentation
lazily, so the first-ever execution of a function delivers fewer events than
later ones — which silently breaks replay and DFS revisits. sys.monitoring
delivers identical events from the first execution on.
By default your code is traced and the stdlib/site-packages are not (their
calls execute atomically, except for the modelled primitives, which are
explicit scheduling points). Narrow or widen with include=/exclude=
path prefixes. granularity="opcode" (default) catches single-line races
like x += 1; granularity="line" is faster when line-atomicity is
acceptable.
Limitations (honest ones)
- Pure-Python interleavings only. C-extension internals (numpy kernels,
C-implemented containers) execute atomically; races inside them are out of
scope. Note
x += 1is pure Python — most application-level races are. - Models must be deterministic apart from scheduling; replay and DFS
depend on it. Seed your randomness, don't read clocks (
time.monotonic/time.timeare already virtualized for you). - Schedules are interpreter-version-specific. Decisions count bytecode
instructions, and bytecode changes between CPython releases; a schedule
recorded on 3.14 will diverge on 3.12. Replay on the recording version
(it's stored in the schedule, and
replay()warns on mismatch). concurrent.futures.ThreadPoolExecutoris not modelled yet (its workers park on machinery created at import time, before patching). Same for Thread subclasses defined outside the run — prefertarget=orit.spawn.- Logical timeouts. A positive
acquire(timeout=...)/wait(timeout=...)expires only when no thread can run, jumping the virtual clock to its deadline; a zero/negative timeout is an immediate poll. Timeouts order deterministically, not by wall clock. - Bounded search is bounded. DFS explores every schedule within its preemption bound and granularity; it is not full DPOR, and random/PCT are probabilistic. Absence of a failure is evidence, not proof.
- One exploration at a time per process (
explore()is not reentrant).
Why not just run tests in parallel?
pytest-run-parallel and friends answer a different question: "does my
suite happen to fail when threads run concurrently on this machine today?"
The OS scheduler switches rarely and roughly uniformly; the pathological
window in your increment is three bytecodes wide. You can run a racy test
ten thousand times in parallel and never once see the lost update — then
ship, and see it weekly in production. Controlled-scheduler testing inverts
the odds: every schedule is chosen adversarially, failures are replayable,
and "no failure" can be made to mean "no failing schedule within this
bound".
Roadmap
- Dynamic partial-order reduction (DPOR) to prune equivalent schedules
ThreadPoolExecutor/concurrent.futuresmodelling- A pytest plugin (
--interleaveto re-run marked tests under exploration) - Atomicity annotations for C-extension calls
- Schedule minimization (shrink a failing schedule to its racy core)
Related work
loom (Rust), CHESS (Microsoft Research), PCT (Burckhardt et al., ASPLOS 2010), rr chaos mode, pytest-run-parallel.
License
MIT © Danny Kissel
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file interleave_test-0.1.0.tar.gz.
File metadata
- Download URL: interleave_test-0.1.0.tar.gz
- Upload date:
- Size: 38.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
25684dfa5292ce3e2c74accc906b3ebeef9cdcfbce1204b7b3260b3b0929d434
|
|
| MD5 |
97128cd68d7f812b6454734a6c937879
|
|
| BLAKE2b-256 |
337f2bf160fd76d7cdc85643cb10daeb7edf436f560c31c78adc8a99ca155b15
|
File details
Details for the file interleave_test-0.1.0-py3-none-any.whl.
File metadata
- Download URL: interleave_test-0.1.0-py3-none-any.whl
- Upload date:
- Size: 31.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c3f1c27a1a952d114e92f7630251dc81e370cfa9db73948d1b5ca7a1d2652218
|
|
| MD5 |
be1f8e9ee90a4925b8c276ee5ae4a777
|
|
| BLAKE2b-256 |
563e74c3b1995cb5e24ef564f315ad4b60f148c2da5493a0e0af066f7ab0c5c3
|