Skip to content

ci: run the test suite on free-threaded CPython (3.14t) - #111

Merged
bagowix merged 1 commit into
mainfrom
feat/free-threaded-ci
Aug 1, 2026
Merged

ci: run the test suite on free-threaded CPython (3.14t)#111
bagowix merged 1 commit into
mainfrom
feat/free-threaded-ci

Conversation

@bagowix

@bagowix bagowix commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

interlock has always documented the breaker as thread-safe: one threading.Lock
in _engine.py guards admission and recording, and the protected callable runs
outside it. Every interpreter in the CI matrix had a GIL, so nothing in CI could
falsify that claim.

3.14t (free-threaded) joins the quality matrix as a required job, and a
new tests/test_concurrency.py drives a single breaker from many threads at
once, asserting what the lock exists to provide:

  • window counts add up under concurrent recording;
  • snapshot() never returns a torn view;
  • the HALF_OPEN caps (max_concurrent_probes, permitted_calls_in_half_open)
    are never exceeded;
  • a Registry hands out exactly one breaker per name;
  • a concurrent trip emits exactly one CLOSED → OPEN.

No races were found in the code as it stands. The tests are sensitive rather
than decorative — with Engine._lock replaced by a nullcontext, two of them
fail 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.

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 — and filterwarnings = "error" turns
that 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-parallel to 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 tests
assert exact per-instance event counts, so running each of them from 4 threads
produces ~100 failures that are test-design artefacts (assert 10 == 5), plus
hard 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 by
weakening the assertions it runs. The acceptance criteria it was meant to serve
are met by test_concurrency.py, which owns its threads and can therefore
measure peak concurrency and rendezvous deterministically.

Checklist

  • Tests added or updated (suite stays at 100% coverage)
  • uv run ruff format --check and uv run ruff check pass
  • uv run mypy and uv run pyright pass
  • Docs updated (docs/) for user-facing changes
  • CHANGELOG.md [Unreleased] updated
  • Commits follow Conventional Commits

Verified locally on cpython-3.14.5+freethreaded: 523 passed (suite minus the
Redis 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

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
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Coverage report

This PR does not seem to contain any modification to coverable code.

@codspeed-hq

codspeed-hq Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 21 untouched benchmarks


Comparing feat/free-threaded-ci (8b2f171) with main (19e5460)

Open in CodSpeed

@bagowix
bagowix merged commit faf35f6 into main Aug 1, 2026
13 of 14 checks passed
@bagowix
bagowix deleted the feat/free-threaded-ci branch August 1, 2026 17:34
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`.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run the test suite on free-threaded CPython (3.14t)

1 participant