Quick Start¶
Finding a race automatically¶
The front door is frontrun.explore(): hand it a way to build shared
state, some workers, and an invariant, and DPOR systematically explores every
meaningfully different thread interleaving until one breaks the invariant or
all of them are proven safe. No annotations needed:
import frontrun
class Counter:
def __init__(self):
self.value = 0
def increment(self):
temp = self.value
self.value = temp + 1
def test_increment_is_atomic():
result = frontrun.explore(
setup=Counter,
workers=Counter.increment,
count=2,
invariant=lambda c: c.value == 2,
)
result.assert_holds()
Run it through the frontrun CLI wrapper — plain pytest skips
frontrun.explore() tests (see Installation):
frontrun pytest test_counter.py
The failure output names the conflicting accesses, shows the interleaved source-line trace, and replays the counterexample schedule to confirm it reproduces deterministically. See DPOR in Practice for the practical guide, and How Frontrun Works for the other exploration strategies (random bytecode exploration, marker schedule exploration).
The rest of this guide covers trace markers — the fully manual approach,
for when you already know the interleaving you’re after and want to pin it
down as a deterministic regression test. If your workers are separate OS
processes contending on a shared database or Redis rather than threads, see
Cross-Process Exploration. If the race you’re hunting involves timeouts, retries,
or TTL expiry, see Virtual clock: timeout, retry, and TTL races (clock="virtual" /
clock="explored").
Triggering a Race Condition with Trace Markers¶
from frontrun.common import Schedule, Step
from frontrun.trace_markers import TraceExecutor
class Counter:
def __init__(self):
self.value = 0
def increment(self):
temp = self.value # frontrun: read_value
temp += 1
self.value = temp # frontrun: write_value
def test_counter_lost_update():
counter = Counter()
# Both threads read before either writes, causing a lost update
schedule = Schedule([
Step("thread1", "read_value"), # T1 reads 0
Step("thread2", "read_value"), # T2 reads 0 (both see same value!)
Step("thread1", "write_value"), # T1 writes 1
Step("thread2", "write_value"), # T2 writes 1 (overwrites T1's update!)
])
executor = TraceExecutor(schedule)
executor.run({
"thread1": counter.increment,
"thread2": counter.increment,
}, timeout=5.0)
assert counter.value == 1 # One increment lost
How Trace Markers Work¶
Trace markers are comments of the form # frontrun: <name> that tell
Frontrun where synchronization points are. A marker gates the code that
follows it: when a thread reaches a marker, it pauses until the scheduler grants
it a turn. Only then does the gated code execute.
Under the hood, each thread runs with a sys.settrace callback that fires
on every source line. The callback checks whether the line contains a
# frontrun: comment (via a MarkerRegistry that caches marker locations
per file). When a marker is hit, the thread blocks on a threading.Condition
until the scheduler advances to that step.
Two placement styles are supported:
Inline with code (marker on the same line as the operation it gates):
def increment(self): temp = self.value # frontrun: read_value temp += 1 self.value = temp # frontrun: write_value
Here
read_valuegates the read ofself.value, andwrite_valuegates the write.On a separate line before the operation:
def increment(self): # frontrun: read_value temp = self.value temp += 1 # frontrun: write_value self.value = temp
The semantics are the same: the marker gates the next executable line.
Name markers after the operation they gate (read_value, write_balance,
acquire_lock, etc.) rather than using temporal prefixes like before_ or
after_.
Creating Schedules¶
A schedule defines the execution order of marked synchronization points:
from frontrun.common import Schedule, Step
schedule = Schedule([
Step("thread1", "marker_name_1"),
Step("thread1", "marker_name_2"),
Step("thread2", "marker_name_1"),
Step("thread2", "marker_name_2"),
])
Each Step specifies the thread/task name and the marker name to execute at
that step.
Running with Controlled Interleaving¶
Pass a {name: callable} dict to run() to start all threads and wait for
them in a single call:
from frontrun.trace_markers import TraceExecutor
executor = TraceExecutor(schedule)
executor.run({
"thread1": task_function_1,
"thread2": task_function_2,
}, timeout=5.0)
This is the preferred form and matches the async API exactly.
Async Support¶
Async trace markers use the same comment-based syntax. Each async task runs in
its own thread (via asyncio.run), with sys.settrace controlling
interleaving between tasks.
A marker gates the next await expression. When a task reaches a marker, it
pauses until the scheduler grants it a turn; only then does the gated await
execute. Between two markers the task runs without interruption from other
scheduled tasks.
from frontrun import TraceExecutor
from frontrun.common import Schedule, Step
class AsyncCounter:
def __init__(self):
self.value = 0
async def get_value(self):
return self.value
async def set_value(self, new_value):
self.value = new_value
async def increment(self):
# frontrun: read_value
temp = await self.get_value()
# frontrun: write_value
await self.set_value(temp + 1)
def test_async_counter_lost_update():
counter = AsyncCounter()
# Both tasks read before either writes --- triggers the lost update
schedule = Schedule([
Step("task1", "read_value"),
Step("task2", "read_value"),
Step("task1", "write_value"),
Step("task2", "write_value"),
])
executor = TraceExecutor(schedule)
executor.run({
"task1": counter.increment,
"task2": counter.increment,
})
# Both tasks read 0, then both write 1 --- one increment is lost
assert counter.value == 1
def test_async_counter_serialized():
counter = AsyncCounter()
# Serialized: task1 completes before task2 starts
schedule = Schedule([
Step("task1", "read_value"),
Step("task1", "write_value"),
Step("task2", "read_value"),
Step("task2", "write_value"),
])
executor = TraceExecutor(schedule)
executor.run({
"task1": counter.increment,
"task2": counter.increment,
})
assert counter.value == 2 # No lost update
Prefer assert_holds() over manual asserts¶
Instead of writing assert result.property_holds, result.explanation after
every exploration call, use the convenience helper InterleavingResult.assert_holds():
result = frontrun.explore(setup=setup, workers=[thread1, thread2], invariant=invariant)
result.assert_holds() # raises AssertionError with explanation on failure
An optional msg_prefix is prepended to the explanation, which is handy
when multiple assertions appear in one test:
result.assert_holds(msg_prefix="counter race: ")