Metadata-Version: 2.4
Name: racecheck
Version: 0.1.0
Summary: Force concurrent operations to collide and report broken invariants on free-threaded Python.
Project-URL: Homepage, https://github.com/slepp/racecheck
Project-URL: Repository, https://github.com/slepp/racecheck
Project-URL: Issues, https://github.com/slepp/racecheck/issues
Author: Stephen Olesen
License-Expression: MIT OR Apache-2.0
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Keywords: concurrency,data-race,free-threading,gil,testing,toctou
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Free Threading
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: test
Requires-Dist: pytest>=7; extra == 'test'
Description-Content-Type: text/markdown

# 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

```sh
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):

```python
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:

```python
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:

```python
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`](https://github.com/slepp/racecheck/blob/main/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](LICENSE-APACHE)) or MIT license ([LICENSE-MIT](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.
