Skip to main content

racecheck

racecheck runs your operations concurrently against one object, many times, and checks your predicate on the final state. It finds check-then-act data races that free-threaded Python (PEP 703) exposes once the GIL is gone.

Install

pip install racecheck

No runtime dependencies. Requires Python 3.10+.

Example

This function is very unlikely to break under the GIL and broken without it. Two threads run it at once, both read patient.dosage, both pass the check, both write, and dosage ends above the limit (Mark Shannon's example, discuss.python.org #93339):

SAFE_DOSAGE = 50


class Patient:
    def __init__(self):
        self.dosage = 0


def increase_dosage(patient, amount):
    if patient.dosage + amount < SAFE_DOSAGE:  # check
        patient.dosage += amount  # act

Declare a setup, two or more ops, and an invariant, then assert on the result:

from racecheck import check


def test_increase_dosage_is_atomic():
    result = check(
        setup=Patient,
        ops=[lambda p: increase_dosage(p, 30), lambda p: increase_dosage(p, 30)],
        invariant=lambda p: p.dosage <= SAFE_DOSAGE,
        trials=2000,
    )
    assert result.ok, result.report()

On a free-threaded build that assertion can fail; report() names the invariant and shows the trial, seed, switch interval and final state. One lock around the check and the act makes it pass:

import threading

lock = threading.Lock()


def increase_dosage(patient, amount):
    with lock:
        if patient.dosage + amount < SAFE_DOSAGE:
            patient.dosage += amount

Run the test on a free-threaded build (uv python install 3.14t).

API

check(*, setup, ops, invariant, trials=2000, seed=None, collect=1, timeout=10.0, invariant_name=None, warn_on_gil=True) -> Result

  • setup builds fresh state for each trial. An exception it raises is not a finding; it propagates out of check.
  • ops are two or more callables, each taking the state and running in its own thread, released together on a barrier that raises the chance they overlap. The barrier cannot force a collision, only make one likelier. Return values are discarded.
  • invariant is checked against the final state. Returning False or raising is a violation, as is an operation that raises; a raising operation short-circuits the invariant for that trial. KeyboardInterrupt and SystemExit are re-raised, not recorded.
  • seed seeds the only randomness, the per-trial switch interval. It reproduces the sequence of switch intervals, not the interleaving, which the interpreter schedules.
  • collect stops the run after this many violations, so it changes how many trials actually run.
  • timeout bounds the wait for a trial's operations to finish, measured once their threads start. Overrunning raises TimeoutError; the abandoned worker keeps running, so the run stops.

Result exposes ok, trials (trials actually run, which the first violation or collect can cut short), seed, invariant, gil_enabled, violations, and report().

Violation exposes trial, seed, switch_interval, state, and error. state is a rendered string of the final state, using field values for a plain class and repr otherwise, truncated to 200 characters.

gil_enabled() -> bool reports whether the interpreter currently holds the GIL. When it does, check emits GILEnabledWarning and report() says the run proved nothing.

Limitations

racecheck triggers races; it cannot prove their absence. CPython gives no control over the scheduler, so a clean run means only that no violation surfaced under the interleavings this run tried. A reported violation means the invariant failed or something raised; usually a race, but a wrong predicate or an ordinary bug can trigger it too, so read it before concluding.

The invariant sees only the final state, so a race that corrupts state and repairs it before the operations return stays invisible. A Ledger whose writers hold a lock leaves value and checksum equal at the end, so check reports ok=True, while an unsynchronised reader can still observe the half-updated state mid-write. On CPython 3.14.3t one run of examples/ledger_blindspot.py counted 93,386 inconsistent reads out of 255,520 while check stayed clean; on a GIL build the split read is wildly improbable and that run counted none.

These races need a free-threaded build (3.13+, GIL disabled). A thread switch can land at almost any bytecode boundary, so a two-bytecode window can in principle split under the GIL, but hitting it is wildly improbable: the increase_dosage window gave zero violations in 50,000 trials on a GIL build. A wider window that spans a C call or I/O releasing the GIL can still surface under the GIL, and a violation there is worth investigating. check varies sys.setswitchinterval() per trial to shake loose interleavings and restores it afterwards; that setting is process-global, so check holds a module-level lock and concurrent calls run one after another.

Related tools

  • pytest-run-parallel runs each test concurrently in N threads with shared fixtures and a thread_comp fixture that asserts named values agree across threads at a barrier. pytest-freethreaded runs a test in N threads with a fixed iteration count and is no longer maintained.
  • cereggii provides thread-synchronisation utilities (AtomicDict, AtomicInt64, ReadersWriterLock, and more) for writing correct concurrent code rather than finding races in existing code.
  • Linearizability and history checking (Lincheck's territory) is a much larger tool; a final-state invariant is enough for check-then-act races.

racecheck fills a narrower slot: interleave two or more different operations and check one invariant across the combined final state.

License

Licensed under either of Apache License, Version 2.0 (LICENSE-APACHE) or MIT license (LICENSE-MIT) at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

racecheck-0.1.0.tar.gz (20.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

racecheck-0.1.0-py3-none-any.whl (16.2 kB view details)

Uploaded Python 3

File details

Details for the file racecheck-0.1.0.tar.gz.

File metadata

  • Download URL: racecheck-0.1.0.tar.gz
  • Upload date:
  • Size: 20.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.3

File hashes

Hashes for racecheck-0.1.0.tar.gz
Algorithm Hash digest
SHA256 a853de7809f0e8e3cf48c26e7f44828e9dc08fb1f30c0f39a787f4099df9ccc8
MD5 74e932c65675ba4c210608f631a6f58b
BLAKE2b-256 802110e7837fad78e7d4c6a18d89ecbd119dd5d885227046feefe0486dc2dccd

See more details on using hashes here.

File details

Details for the file racecheck-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: racecheck-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 16.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.3

File hashes

Hashes for racecheck-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 71e352a1cfd028296f669a708fc13e65a33cee03213f92e86818baa3b059e7fe
MD5 ebd9fd0554fb6c96f0129af3b8c697c6
BLAKE2b-256 7a2455946cf4e2e19e84019a8e4e7a353479140ca38388d9e43f9f516f4c2deb

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page