ci: run the test suite on free-threaded CPython (3.14t) - #111
Merged
Conversation
The breaker has always been documented as thread-safe — a single threading.Lock guards admission and recording, and the protected callable runs outside it. Every interpreter in the CI matrix had a GIL, so nothing could falsify that claim. Adds 3.14t to the quality matrix as a required job, and tests/test_concurrency.py: many threads through one breaker, asserting what the lock exists to provide — window counts add up, snapshot() is never torn, the HALF_OPEN caps hold, and a Registry gives exactly one breaker per name. A concurrent trip must also emit exactly one CLOSED -> OPEN. The tests are sensitive, not decorative: with Engine._lock replaced by a nullcontext, two of them fail on 3.14t (115 of 192 records lost, the transition emitted twice) and all five still pass on 3.11. No races were found in the code as it stands. fakeredis is now imported only when it is actually the backend. Its Lua engine (lupa) has not declared free-threading support, so importing it on 3.14t re-enables the GIL with a RuntimeWarning that filterwarnings=error turns into a collection error for the whole file. CI already points the job at a real server. Closes #102
Contributor
bagowix
added a commit
that referenced
this pull request
Aug 1, 2026
CI has run the whole suite on free-threaded 3.14t since #111, and tests/test_concurrency.py is what backs the thread-safety claim, but the package metadata said nothing about it. There is no `3.14t` classifier — free-threading is a separate trove axis — so declare `Free Threading :: 3 - Stable` alongside the version classifiers. Verified with `twine check`.
6 tasks
bagowix
added a commit
that referenced
this pull request
Aug 1, 2026
…112) ## Summary `tests/test_state_machine_properties.py` generates a config and then drives a *fixed*, hand-written sequence — good at the boundaries it is pointed at, blind to order-dependent corruption. This adds `tests/test_state_machine_model.py`: a hypothesis `RuleBasedStateMachine` that generates the sequence as well, so an override during a probe round or a probe settling an era late has somewhere to show up. - **Rules:** `advance` (fractions of the open wait, so `1.0` lands exactly on the boundary), `admit`, `settle`, `release`, `auto_transition`, `force_open`, `disable`, `metrics_only`, `reset`. - **Bundle:** an admitted call yields a ticket — the generation the call layer captures — and `settle` / `release` consume one, so probes settle out of order, or an era late. - **Invariants**, after every step, against the model's own prediction rather than values read back from the machine: state, generation (the stale-outcome fence), the #79 contract that no automatic transition undoes an operator override, the probe budget cross-checked against the machine's own accounting, window aggregates vs. the recorded history, and `retry_after()` as the remaining wait in OPEN. - I/O-free: all time comes from `FakeClock`. The `Config` strategy moves to `conftest.py` as `configs()`, now shared by both hypothesis suites. The model narrows `minimum_number_of_calls` on top of it — every recorded outcome costs two steps, so an unnarrowed run spends all 50 steps inside CLOSED and never reaches a probe round. The existing four properties stay unchanged (bar the import). No counterexample against the current implementation, so there is no regression test to pin yet. Verified the model actually bites, by mutation: | Mutation | Result | |---|---| | `_admit_probe`: `>=` → `>` on the concurrency cap | fails (`admit` predicted a rejection) | | `record()`: drop the generation fence | fails (`window_matches_history`) | | `release_probe()`: drop the generation/state guard | fails (`probe_budget_holds`, `-1 == 0`) | Also here, since it is the same free-threading thread: `pyproject.toml` now declares `Programming Language :: Python :: Free Threading :: 3 - Stable`. There is no `3.14t` version classifier — free-threading is a separate trove axis. CI has run the whole suite on 3.14t since #111 and `tests/test_concurrency.py` backs the claim, but the metadata said nothing about it. The #111 changelog entry asserted the opposite ("no wheels, no classifiers"), so it is corrected in the same commit: the wheel half still holds (pure Python — one wheel serves both flavours), the classifier half no longer does. Verified with `twine check` on the built sdist and wheel. ## Checklist - [x] Tests added or updated (suite stays at 100% coverage) — 548 passed, coverage 100%; the model file runs in ~1.0s (`max_examples=200`, `stateful_step_count=50`) - [x] `uv run ruff format --check` and `uv run ruff check` pass - [x] `uv run mypy` and `uv run pyright` pass - [ ] Docs updated (`docs/`) for user-facing changes — n/a: no public API surface changed (the test-suite side is documented in `tests/CLAUDE.md`) - [x] `CHANGELOG.md` `[Unreleased]` updated - [x] Commits follow Conventional Commits ## Related issues Closes #106.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
interlock has always documented the breaker as thread-safe: one
threading.Lockin
_engine.pyguards admission and recording, and the protected callable runsoutside it. Every interpreter in the CI matrix had a GIL, so nothing in CI could
falsify that claim.
3.14t(free-threaded) joins thequalitymatrix as a required job, and anew
tests/test_concurrency.pydrives a single breaker from many threads atonce, asserting what the lock exists to provide:
snapshot()never returns a torn view;max_concurrent_probes,permitted_calls_in_half_open)are never exceeded;
Registryhands out exactly one breaker per name;CLOSED → OPEN.No races were found in the code as it stands. The tests are sensitive rather
than decorative — with
Engine._lockreplaced by anullcontext, two of themfail on 3.14t (115 of 192 records lost; the transition emitted twice) while all
five still pass on 3.11. That gap is the whole argument for the new job.
fakeredisis now imported only when it is actually the backend. Its Lua engine(
lupa) has not declared free-threading support, so importing it on 3.14tre-enables the GIL with a
RuntimeWarning— andfilterwarnings = "error"turnsthat into a collection error for the entire file. CI already points the job at a
real Redis service container, so the extra import was never needed there.
Deviation from the issue
The issue proposed
pytest-run-parallelto re-run an existing subset(
test_breaker.py,test_registry.py,test_engine.py,test_coordination.py,test_observability.py) from N threads. I tried it and dropped it: those testsassert exact per-instance event counts, so running each of them from 4 threads
produces ~100 failures that are test-design artefacts (
assert 10 == 5), plushard errors where it meets
pytest-asyncio(a coroutine was expected, got None). It would have added a dev dependency that could only be made green byweakening the assertions it runs. The acceptance criteria it was meant to serve
are met by
test_concurrency.py, which owns its threads and can thereforemeasure peak concurrency and rendezvous deterministically.
Checklist
uv run ruff format --checkanduv run ruff checkpassuv run mypyanduv run pyrightpassdocs/) for user-facing changesCHANGELOG.md[Unreleased]updatedVerified locally on
cpython-3.14.5+freethreaded: 523 passed (suite minus theRedis file, which needs a server there), 100% coverage on 3.11, and the
contention tests green 15/15 consecutive runs on 3.14t.
Related issues
Closes #102