Cross-Process Exploration¶
Frontrun’s DPOR, bytecode, and marker approaches all interleave concurrency within a single Python process — across threads or async tasks. Cross-process exploration extends the same idea to separate Python processes that contend on shared external state (SQL and Redis).
Each worker process runs frontrun’s SQL/Redis interception and coordinates with
a parent coordinator over a socket. The Rust DPOR engine drives the search, so
equivalent interleavings are pruned (partial-order reduction) and cross-worker
SELECT FOR UPDATE deadlocks are detected — the same guarantees as the
in-process DPOR path, applied at external-access (SQL statement / Redis command)
granularity.
Note
The supported model is: if the workers are Python, run frontrun inside them. There is no scheduling of unmodified non-Python processes.
Because separate processes share no memory, only external-store accesses (SQL statements, Redis commands) are scheduling points — purely in-memory work inside a worker is not interleaved the way threads are. This is coarser than thread scheduling by design: the interesting cross-process races are the ones that go through the shared database or cache.
Unlike the in-process DPOR path, cross-process exploration does not require
the frontrun CLI wrapper (there is no LD_PRELOAD C-level I/O to
intercept); a plain pytest run works, since each worker installs frontrun’s
Python-level SQL/Redis interception itself.
Which entry point?¶
There are two public entry points; pick by how you want to define workers:
|
|
|
|---|---|---|
Worker is |
any callable — closures and lambdas too (serialised with dill) |
a |
Args reach the worker via |
dill serialisation of |
JSON through the environment (scalars/lists/str-keyed dicts only) |
Return type |
|
|
Reach for it when |
you want a drop-in mirror of the threading API |
the target must run in a clean interpreter, or richer JSON args suffice |
explore(execution="process") is the ergonomic default; explore_processes()
is the lower-level door with explicit per-worker specs.
execution="process" — the ergonomic mirror¶
The simplest way in is frontrun.explore() with execution="process". It
has the same setup / workers / invariant shape as the thread and
async interface (including count= to replicate a worker) and returns the same
InterleavingResult (property_holds /
counterexample / explanation / assert_holds).
Install the process extra (pip install frontrun[process]), which pulls in
dill for serialising workers. Two differences are inherent to using processes:
Workers are serialised with dill, so closures and lambdas work as well as module-level functions (dill handles more than the stdlib pickle that
multiprocessinguses). Genuinely unserialisable captures — an open connection, socket, or file handle held by the worker — still fail, with a clear error.setup()returns a handle to the external state — a DB URL, a SQLite path, a Redis key namespace — rather than a live Python object. The handle is passed to everyworker(state)and toinvariant(state). State lives in the external store (SQL/Redis), not in shared Python memory.
setup and invariant run in the coordinator process; the workers run in
their own spawned processes.
Because this entry point uses multiprocessing with the spawn start
method, the parent program must be a file-backed Python module. Running it from
stdin, python -c, or a REPL/notebook cell is rejected with a clear error
before workers start. Put the test in a .py file, or use
explore_processes() with importable "module:callable" targets.
import sqlite3
import frontrun
# Module-level (picklable) worker: a racy read-modify-write over two statements.
def increment(db_path):
conn = sqlite3.connect(db_path, isolation_level=None)
try:
val = conn.execute("SELECT val FROM counter WHERE id = 1").fetchone()[0]
conn.execute("UPDATE counter SET val = ? WHERE id = 1", (val + 1,))
finally:
conn.close()
def read(db_path):
conn = sqlite3.connect(db_path, isolation_level=None)
try:
return conn.execute("SELECT val FROM counter WHERE id = 1").fetchone()[0]
finally:
conn.close()
def test_counter_race(tmp_path):
db = str(tmp_path / "counter.db")
def setup():
conn = sqlite3.connect(db, isolation_level=None)
conn.execute("CREATE TABLE IF NOT EXISTS counter (id INTEGER PRIMARY KEY, val INTEGER)")
conn.execute("DELETE FROM counter")
conn.execute("INSERT INTO counter (id, val) VALUES (1, 0)")
conn.close()
return db # picklable handle passed to each worker(state) and invariant(state)
result = frontrun.explore(
setup=setup,
workers=increment,
count=2,
invariant=lambda state: read(state) == 2,
execution="process",
)
assert not result.property_holds # lost-update race found across processes
assert result.counterexample is not None
Compare with the same test written against a safe, single-statement increment
(UPDATE counter SET val = val + 1): the invariant holds under every
interleaving and result.property_holds is True. As everywhere else,
property_holds is tri-state: True is a pass certificate backed by the
coordinator’s evidence (completed iterations, every worker process observed to
finish), False implies a failure record exists, and None means the run
was inconclusive — e.g. total_timeout expired before a single interleaving
completed — with the cause in result.inconclusive_reason.
execution="process" accepts sync "dpor" only. Async workers and other
strategies raise ValueError (SQL/Redis state is external, so async worker
support and random scheduling do not apply to the process path). SQLite needs
nothing extra; a Redis worker needs the redis package and a running server.
explore_processes() — the lower-level entry¶
frontrun.explore_processes() is the underlying API. Instead of pickled
callables, it spawns each worker as a real OS process running a
"module:callable" target under a fresh interpreter, described by a
frontrun.Subprocess spec:
import frontrun
_TARGET = "myapp.counter:increment" # your importable module-level worker
def test_lost_update_across_processes(tmp_path):
db = str(tmp_path / "counter.db")
result = frontrun.explore_processes(
{
"w0": frontrun.Subprocess(_TARGET, (db,)),
"w1": frontrun.Subprocess(_TARGET, (db,)),
},
setup=lambda: reset_counter(db), # your DB reset helper
invariant=lambda _state: read_counter(db) == 2,
)
assert not result.ok
assert result.failure_kind == "invariant"
assert result.failing_schedule is not None
processes is a mapping of label → Subprocess (labels are
preserved in result.worker_labels) or a plain sequence. Subprocess(target, args) names a
"module:callable" and its positional args; the args are passed to the
child as JSON through the environment, so they must be JSON-serialisable
and survive a JSON round-trip: a tuple arrives as a list and a dict with
non-string keys comes back string-keyed. Pass plain scalars, lists, and
string-keyed dicts — or use frontrun.explore(execution="process") (which
pickles) when you need richer argument types. Because the child imports the
target by name, it must be importable in a fresh interpreter — a module-level
callable in an installed or on-path module.
Targets must be synchronous callables. If a target is async def (or a
plain callable that returns an awaitable), the worker reports a clear error;
cross-process exploration has no asyncio scheduler in the child process.
setup and invariant both run in the coordinator process. setup
resets the external state before each interleaving and returns a handle to it;
that handle is passed to invariant(state), which checks the state afterwards
— mirroring explore(execution="process"). invariant may ignore
state and reach the shared store directly.
explore_processes() returns a frontrun.CrossProcessResult
(importable from the top-level package):
Field |
Meaning |
|---|---|
|
|
|
Human-readable description of the first violation, or |
|
One of |
|
The interleaving (a list of worker ids) that triggered the failure. |
|
For mapping input, the |
|
Every failing execution as |
|
Number of interleavings explored. |
|
|
|
The external accesses observed on the failing run, as
|
The InterleavingResult returned by
explore(execution="process") carries the same structured information:
exhausted, failure_kind, and failures are copied from the
underlying CrossProcessResult alongside the human-readable
explanation. An ok result is converted through the pass-certificate
chokepoint: it certifies property_holds=True only with at least one
completed iteration and clean-completion evidence for every worker, and a
zero-iteration truncation (e.g. total_timeout expiring during worker
startup) becomes property_holds=None with the truncation cause in
inconclusive_reason.
Strategies and worker reuse¶
strategy selects the coordinator:
"dpor"(default) drives the Rust DPOR engine, pruning equivalent interleavings and detecting cross-workerSELECT FOR UPDATEdeadlocks.max_executions,preemption_bound,max_branches, andtotal_timeoutbound the search,searchselects the wakeup-tree traversal order, andstop_on_first=Falsekeeps exploring after a failure, accumulating every failing execution inresult.failures."exhaustive"brute-forces every interleaving at external-access granularity, bounded bymax_iterationsand bymax_steps_per_runper execution. Useful as a reduction-free cross-check that DPOR reaches the same verdict.
Each strategy rejects the other’s bounds when passed explicitly:
max_iterations and max_steps_per_run only apply to "exhaustive"
(bound a DPOR search with max_executions instead), and the DPOR knobs above
only apply to "dpor". Detection is value-based, so passing a default value
is indistinguishable from omitting it.
reuse_workers=True keeps the worker processes alive across iterations,
re-running the target in place instead of respawning for each interleaving. The
verdict is identical; reuse trades startup cost for the target being run
repeatedly in one process, so the target’s process-global state must be safe to
re-enter (frontrun resets its own per-connection SQL state between iterations).
It is available on both entry points with DPOR —
explore_processes(..., reuse_workers=True) and
frontrun.explore(..., execution="process", reuse_workers=True). The
lower-level exhaustive strategy rejects worker reuse.
If a reused iteration deadlocks or aborts, frontrun kills and reaps the poisoned
worker processes, then launches a fresh set before continuing the search. This
loses process-global re-entry state at that boundary, but never feeds another
schedule into a desynchronised protocol stream. Thread execution rejects
reuse_workers=True with ValueError because Python cannot safely kill an
arbitrary stuck thread. Independently, detected thread deadlocks are returned
as failed InterleavingResult values rather than raised directly.
result = frontrun.explore_processes(
{"w0": frontrun.Subprocess(_TARGET, (db,)), "w1": frontrun.Subprocess(_TARGET, (db,))},
setup=lambda: reset_counter(db),
invariant=lambda _state: read_counter(db) == 2,
reuse_workers=True, # spawn each worker once, re-run per interleaving
)
Deadlock detection¶
When workers take row locks (SELECT ... FOR UPDATE) in conflicting orders,
the coordinator’s wait-for graph detects the cycle and reports
failure_kind == "deadlock" — the same machinery as the in-process DPOR
path. This needs a store with real row locks (PostgreSQL/MySQL); SQLite has
none, so the example requires a running Postgres:
# myapp/transfer.py (importable target)
import psycopg2
def lock_two(dsn, first_id, second_id):
conn = psycopg2.connect(dsn)
conn.autocommit = False
cur = conn.cursor()
cur.execute("SELECT * FROM accounts WHERE id = %s FOR UPDATE", (first_id,))
cur.execute("SELECT * FROM accounts WHERE id = %s FOR UPDATE", (second_id,))
conn.commit()
conn.close()
Then (dsn and reset_accounts are your own connection string and
table-reset helper):
# Two workers lock rows 1 and 2 in opposite order: a classic deadlock.
result = frontrun.explore_processes(
{
"w0": frontrun.Subprocess("myapp.transfer:lock_two", (dsn, 1, 2)),
"w1": frontrun.Subprocess("myapp.transfer:lock_two", (dsn, 2, 1)),
},
setup=lambda: reset_accounts(dsn),
invariant=lambda _state: True, # we are looking for the deadlock, not a data race
stop_on_first=False,
)
assert not result.ok
assert result.failure_kind == "deadlock"
assert result.failing_schedule is not None
stop_on_first=False matters here: physical row-lock contention can
redirect an early interleaving before the lock cycle is reached, and with
stop_on_first=True exploration stops at that first (fail-closed,
failure_kind="nondeterministic") result. Letting exploration continue
allows the concrete deadlock cycle to supersede it.
Redis workers¶
The same interface works against Redis. A worker connects to a Redis server and performs a racy GET/SET, while the coordinator resets and checks the counter:
import frontrun
_TARGET = "myapp.redis_counter:increment" # your importable Redis worker
def test_redis_lost_update():
result = frontrun.explore_processes(
{"w0": frontrun.Subprocess(_TARGET), "w1": frontrun.Subprocess(_TARGET)},
setup=reset_redis_counter,
invariant=lambda _state: read_redis_counter() == 2,
)
assert not result.ok
assert result.failure_kind == "invariant"
Redis exploration requires the redis package and a running server (the demo
workers read FRONTRUN_XPROC_REDIS_URL, defaulting to a local instance). The
atomic variant — Redis INCR — has no race, so result.ok is True.
The ergonomic execution="process" door works against Redis too — the
setup() return value is the picklable handle (here the Redis URL) passed to
each worker(state):
import redis
import frontrun
REDIS_URL = "redis://127.0.0.1:6379/0"
def increment(url):
r = redis.Redis.from_url(url)
current = int(r.get("counter") or 0)
r.set("counter", current + 1)
def setup():
redis.Redis.from_url(REDIS_URL).set("counter", 0)
return REDIS_URL # picklable handle passed to each worker(state)/invariant(state)
result = frontrun.explore(
setup=setup,
workers=increment,
count=2,
invariant=lambda url: int(redis.Redis.from_url(url).get("counter")) == 2,
execution="process",
)
assert not result.property_holds # lost update found across processes
Running the tests¶
Cross-process tests spawn real processes and are marked with the pytest e2e
marker. They run as part of the default suite (make test-3.14 applies no
marker filter); the marker exists so they can also be selected on their own:
make test-e2e-3.14 # only the cross-process e2e tests, 3.14 venv
pytest -m e2e # or select the marker directly
The SQLite tests need nothing beyond the standard library. The Redis tests are
additionally marked integration and are skipped unless redis is
installed and a server is reachable.