Skip to content

Investigate free-threaded Python (PEP 703) support - #84

Open
wbarnha wants to merge 10 commits into
masterfrom
claude/mode-free-threaded-python-s61d3c
Open

Investigate free-threaded Python (PEP 703) support#84
wbarnha wants to merge 10 commits into
masterfrom
claude/mode-free-threaded-python-s61d3c

Conversation

@wbarnha

@wbarnha wbarnha commented Aug 7, 2026

Copy link
Copy Markdown
Member

mode is pure Python, so it already installs, imports and passes its full
test suite (757 passed, 2 skipped) on CPython 3.14.0rc2 free-threaded with
the GIL disabled -- no packaging work is required. What free threading
changes is that several latent thread-safety defects stop being
theoretical.

Measured on python3.14t with a GIL-enabled 3.14.0rc2 as the control:

  • LRUCache is backed by collections.OrderedDict with thread_safety=False
    by default, so concurrent setitem eviction and keys() iteration run
    unlocked. This SEGFAULTS a free-threaded interpreter (4/5 runs, plus a
    hang); the GIL build is unaffected. Isolated to OrderedDict itself --
    the same loop against a plain dict survives every run, because
    free-threaded CPython gives plain dict per-object locking and
    OrderedDict's C implementation did not get the same treatment.

  • cached_property.get is a non-atomic check-then-act on obj.dict:
    104/300 trials handed out more than one distinct object (0/300 on the
    GIL build). ServiceProxy documents @cached_property as the way to build
    the proxied service, and 198/200 trials built more than one Service
    instance, so a start() and a later stop() can reach different objects.

  • mode/init.py swaps sys.modules["mode"] for a _module instance at the
    end of its body, so a thread importing mode concurrently can receive the
    pre-swap module and AttributeError on every lazily-exported name.
    Pre-existing, but 14/25 runs fail free-threaded vs 3/25 under the GIL.
    PEP 562 module getattr removes the swap entirely.

  • Signal iterates its receiver set while connect/disconnect mutate it.
    Pre-existing, not a free-threading regression: 30/30 trials raise on
    both builds.

  • mode[gevent] re-enables the GIL at import (gevent.libev.corecext is not
    declared free-threading safe). mode[uvloop] and mode[eventlet] leave it
    disabled.

Adds docs/free-threading.md with the full analysis and a suggested order
of work, and tests/freethreading/stress.py with the reproducers. The
latter sits outside the testpaths configured in pyproject.toml so the
crash reproducers are never collected by a normal pytest run.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD

claude added 10 commits August 7, 2026 19:42
mode is pure Python, so it already installs, imports and passes its full
test suite (757 passed, 2 skipped) on CPython 3.14.0rc2 free-threaded with
the GIL disabled -- no packaging work is required.  What free threading
changes is that several latent thread-safety defects stop being
theoretical.

Measured on python3.14t with a GIL-enabled 3.14.0rc2 as the control:

- LRUCache is backed by collections.OrderedDict with thread_safety=False
  by default, so concurrent __setitem__ eviction and keys() iteration run
  unlocked.  This SEGFAULTS a free-threaded interpreter (4/5 runs, plus a
  hang); the GIL build is unaffected.  Isolated to OrderedDict itself --
  the same loop against a plain dict survives every run, because
  free-threaded CPython gives plain dict per-object locking and
  OrderedDict's C implementation did not get the same treatment.

- cached_property.__get__ is a non-atomic check-then-act on obj.__dict__:
  104/300 trials handed out more than one distinct object (0/300 on the
  GIL build).  ServiceProxy documents @cached_property as the way to build
  the proxied service, and 198/200 trials built more than one Service
  instance, so a start() and a later stop() can reach different objects.

- mode/__init__.py swaps sys.modules["mode"] for a _module instance at the
  end of its body, so a thread importing mode concurrently can receive the
  pre-swap module and AttributeError on every lazily-exported name.
  Pre-existing, but 14/25 runs fail free-threaded vs 3/25 under the GIL.
  PEP 562 module __getattr__ removes the swap entirely.

- Signal iterates its receiver set while connect/disconnect mutate it.
  Pre-existing, not a free-threading regression: 30/30 trials raise on
  both builds.

- mode[gevent] re-enables the GIL at import (gevent.libev.corecext is not
  declared free-threading safe).  mode[uvloop] and mode[eventlet] leave it
  disabled.

Adds docs/free-threading.md with the full analysis and a suggested order
of work, and tests/freethreading/stress.py with the reproducers.  The
latter sits outside the testpaths configured in pyproject.toml so the
crash reproducers are never collected by a normal pytest run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
These are races, so the rates quoted in the findings are representative
single runs rather than stable constants.  Record the observed ranges so
a reader who reproduces them and sees different numbers knows that is
expected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
Implements the four fixes from docs/free-threading.md.  Verified on
CPython 3.14.0rc2 free-threaded, GIL-enabled 3.14.0rc2, and 3.10 (the
floor): 778 passing, ruff and mypy clean on all three.

LRUCache could segfault the interpreter.  It was backed by an
OrderedDict with thread_safety=False by default, so eviction and
iteration ran unlocked; concurrent mutate+iterate exited SIGSEGV in 4 of
5 runs on a free-threaded build.  The cause was isolated to OrderedDict
-- free-threaded CPython gives plain dict per-object locking but did not
convert OrderedDict's C linked list -- so the backing store is a plain
dict now (insertion-ordered since 3.7; the only OrderedDict-specific API
in use was popitem(last=...)).  thread_safety additionally defaults to
on for free-threaded builds via the new FREE_THREADED flag, checked at
runtime so PYTHON_GIL=1 is respected.  Iteration now snapshots under the
mutex instead of holding it across yields, which would otherwise have
kept the lock held for as long as the consumer took to iterate -- and
forever if it abandoned the generator.

cached_property handed different objects to different threads: __get__
was a non-atomic check-then-act on obj.__dict__, and 104/300 trials
returned more than one distinct object.  ServiceProxy documents
@cached_property _service as the way to build the proxied service, and
198/200 trials built more than one Service, so start() and stop() could
act on different instances.  The miss path is double-checked under a
per-descriptor lock now; the already-cached path stays lock-free.

Concurrent cold `import mode` could hand back a half-built module.
mode/__init__.py swapped sys.modules["mode"] for a ModuleType subclass
at the end of its body, so a thread importing concurrently could receive
the pre-swap object -- which has no __getattr__ -- and AttributeError on
every lazily-exported name (14/25 runs free-threaded, 3/25 under the
GIL).  The replacement also carried no __spec__, denying the import
machinery the _initializing flag that would have made the second thread
wait.  Replaced with a PEP 562 module __getattr__ plus module __dir__,
so there is no swap to race with.  The exported surface is unchanged:
same 25 names, same identities, star-import and dir() equivalent.  The
one visible difference is that mode.__all__ is now the list declared in
the source rather than a tuple the swap substituted.

Signal iterated its receiver set while connect/disconnect mutated it,
raising "Set changed size during iteration" 30/30 on both builds -- a
pre-existing bug, not a free-threading regression.  It snapshots now,
with list() rather than tuple(): list()/set()/set.copy() take the source
set's per-object lock for the copy, while tuple() falls back to generic
iteration and does not.  The first attempt used tuple() and still failed
8/8; the stress harness caught it.

Also adds tests/functional/test_thread_safety.py (21 tests, every one
verified to fail against the pre-fix tree), puts 3.14t in the CI matrix,
declares the Free Threading :: 2 - Beta classifier, and notes next to
the gevent extra that gevent.libev.corecext re-enables the GIL -- the
one item here that cannot be fixed from this side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
Importing gevent re-enables the GIL, and nothing about that is visible to
the caller -- the process keeps running and simply is not free-threaded
any more.  The pyproject note is invisible at runtime, so warn from
mode/loop/gevent.py instead.

The check reads the build flag via sysconfig rather than
sys._is_gil_enabled(), because the runtime check would already report
True by the time gevent has been imported -- which is the very situation
being reported.

Also records in docs/free-threading.md that mode.loop.use("gevent") is
currently broken on every build, which is unrelated to free threading: it
fails identically on GIL-enabled 3.10 and 3.14 with gevent 26.7.0.
mode/loop/gevent.py points GEVENT_LOOP at mode.loop._gevent_loop.Loop,
but that module imports gevent.core at module scope to subclass
gevent.core.loop, so importing it builds a gevent hub, which resolves
GEVENT_CONFIG.loop, which imports the same module before its body has
reached `class Loop`.  The cycle is inside the module's own import, so
pre-importing it does not help.  gevent alone is fine -- patch_all() plus
asyncio_gevent.EventLoopPolicy runs a coroutine correctly -- only mode's
custom hook fails.  mode.loop has no test coverage, which is how this
went unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
It is unmaintained, has no test coverage, re-enables the GIL on
free-threaded builds, and does not currently work on any interpreter --
`mode.loop.use("gevent")` fails with "Cannot import 'Loop' from
mode.loop._gevent_loop" against current gevent releases, on GIL-enabled
3.10 and 3.14 alike.

Nothing is removed, so this is not a breaking change: the backend still
resolves exactly as before, and now raises a DeprecationWarning naming
the breakage and pointing at the aio and uvloop backends.

The warning is raised from `mode.loop.use()` rather than from
mode/loop/gevent.py's module body.  A module-level `warnings.warn` is
attributed to whichever importlib frame executed the body, and
DeprecationWarning is filtered out everywhere except __main__, so it was
never actually shown -- verified before moving it.  Raised from `use()`
with stacklevel=2 it lands on the caller, which is where the backend
gets selected.

Adds tests/unit/test_loop.py, the first coverage mode.loop has had.  It
patches importlib.import_module throughout: really selecting a backend
applies process-wide monkey-patches that would wreck every test running
afterwards, which is part of why this module went untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
The 3.14t job added in the previous commit failed at setup, five seconds
in, before running anything:

  The version '3.14t' with architecture 'x64' was not found for
  Ubuntu 24.04.

The build exists -- actions/python-versions ships
python-3.14.7-linux-24.04-x64-freethreaded.tar.gz.  The problem is that
tests.yml pinned actions/setup-python@v4, and the free-threaded "t"
suffix is only understood from v5.3 onwards.  On v4 the string "3.14t" is
treated as a literal version and looked up against arch x64 rather than
x64-freethreaded, hence "not found".

Bumps checkout to v4 in the same file while there: tests.yml was the last
workflow still on checkout@v3 and setup-python@v4, and both deploy-docs
and publish already use v4/v5.

Verified by running each CI step against python3.14t locally: pip install
-r requirements.txt (exit 0, docs deps included), pip install -r
requirements-typecheck.txt (exit 0), scripts/lint.sh (clean), and
scripts/tests.sh (790 passed, 1 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
scripts/tests.sh ran plain pytest with no --cov, so nothing was ever
measured.  Two consequences: the `fail_under = 93` configured in
pyproject.toml was never enforced, and the Codecov step in tests.yml
failed on every leg of every run with "No coverage reports found" -- as a
warning, which is why it went unnoticed.

Switching --cov on alone would have turned CI red: coverage sits at
92.66% on master and 92.75% on this branch, both under the threshold.  So
this also covers mode/utils/loops.py, which was the single largest gap at
34% -- get_event_loop was tested but _is_unix_loop, clone_loop,
_appropriate_signal_handler, call_asap and _call_asap had nothing at all.
That takes loops.py to 92% and the project to 93.69%, clearing the bar
with room to spare (94.76% on 3.10).

Two pre-existing bugs turned up while writing those tests.  Neither is
fixed here -- both are in code with no callers inside mode, and changing
exported behaviour belongs in its own change:

- _call_asap dispatches the callback twice, once via loop._call_soon()
  and again via the handle it inserts at _ready[0].
- get_event_loop() can return a closed loop: it checks is_closed() on its
  own thread-local cache, then falls through to asyncio.get_event_loop(),
  which returns whatever was last passed to set_event_loop() even when
  that loop is closed.

The new tests assert the documented contract rather than either bug, so
they keep passing if and when those are fixed.  Both are noted in
comments at the point where a reader would otherwise be confused.

Note that Codecov *upload* still cannot succeed: the runs log "Branch is
protected but no token was provided", so secrets.CODECOV_TOKEN is not set
on the repository.  That needs a maintainer.  The local fail_under gate
now works regardless of the upload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
The pypy3.10 leg started failing at collection once coverage was switched
on in the previous commit:

  mode/locals.py:290: in Proxy
      @__class__.setter
  E   NameError: name '__class__' is not defined

`Proxy.__init_subclass__` calls zero-argument `super()`, which makes the
compiler add an implicit `__class__` closure cell to the class.  That
means the bare `__class__` written by the `@property` / `@__class__
.setter` decorator pair is not a plain namespace lookup: CPython resolves
it to the property object defined moments earlier, but PyPy resolves it
to the cell, which stays empty until the class object exists.  PyPy only
takes that path with a trace function installed, which is why it appeared
under coverage and never before.

Building the property as `property(_get_class, _set_class)` stores the
name without ever loading it, which sidesteps the question on every
interpreter.  Behaviour is unchanged -- verified against the pre-fix tree:
same resolution through the proxy, same TypeError from assignment (which
`Proxy.__setattr__` intercepts and forwards before the setter is ever
reached), same `property` descriptor on the class.

Adds a regression guard asserting the class body emits no LOAD_NAME /
LOAD_CLASSDEREF / LOAD_GLOBAL for `__class__`, while tolerating the
compiler's own MAKE_CELL / LOAD_FAST cell plumbing.  CPython cannot
reproduce the failure itself, so the bytecode is the only thing a
CPython-only run can check; the guard fails on the pre-fix tree with
exactly ['LOAD_NAME'].

Not verified on PyPy directly: the sandbox proxy blocks downloads.python
.org and pypy.org, so the diagnosis was confirmed by disassembling the
class body rather than by running it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
Follow-up to the previous commit, which fixed the PyPy import error but
broke `proxy.__class__` on PyPy instead: test_Proxy::test_name failed
with the proxy reporting itself rather than the object it wraps.

Two separate constraints apply here, and only one shape satisfies both.

The class has an implicit `__class__` closure cell.  The previous commit
claimed zero-argument super() causes that and can be avoided by naming
the class explicitly -- wrong on the second point: the compiler adds the
cell when a method merely *references the name* `super`, since it cannot
know which form is meant.  `super(Proxy, self)` makes no difference, so
that part is reverted and the comment corrected.

Given the cell exists:

1. The name must be bound with `def`.  On PyPy a class-body assignment to
   a name that is also a cell variable does not reach the class
   namespace, so `__class__ = property(...)` -- the previous commit's fix
   -- left no descriptor at all; attribute access fell back to
   `type.__class__`.  CPython installs it either way, which is why CI
   caught this and local runs could not.

2. The class body must not *read* the bare name `__class__`, which the
   `@property` / `@__class__.setter` pair does to attach the setter.  With
   the cell present that read resolves to the cell, empty until the class
   exists, raising NameError at import on PyPy under a trace function.

Passing the setter to the decorator up front (`_property_with_setter`)
keeps the `def` binding while removing the read.

Each guard is verified to catch one failure mode, by editing the real
module and re-running: the decorator form trips
test_class_body_never_looks_up_the_bare_name, the assignment form trips
test_the_name_is_bound_with_def.  The earlier store-opcode check is
replaced -- CPython emits STORE_NAME for both spellings, so it could not
tell them apart; the presence of a nested code object named `__class__`
can.

Still not verified on PyPy directly: the sandbox proxy blocks
downloads.python.org and pypy.org.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
Third and, I think, correct attempt at the PyPy failure.  The two before
this each fixed one symptom and caused the other, because I had the
mechanism wrong both times.

What is actually going on: `Proxy` defines a `__class__` property, and the
compiler gives the class an implicit `__class__` closure cell as soon as
any method in the body so much as names `super`.  On PyPy -- and only with
a trace function installed, i.e. under coverage -- a class body carrying
that cell resolves *every* mention of the name `__class__` to the cell
instead of to the class namespace.  Both directions break, which is why
fixing one exposed the other:

- reading it, as `@__class__.setter` must, hits the cell while it is still
  empty  -> NameError at import (the first failure);
- binding it writes to the cell, leaving no descriptor on the class
  -> proxies report themselves instead of the object they wrap (the
  second failure, from the previous commit's `property()` assignment, and
  from its `def` form too -- `def` was not the distinction I claimed).

So no spelling of the property inside that class body can work while the
cell exists.  The cell has to go.  Moving the cooperative
`super().__init_subclass__()` call into a module-level helper removes the
only mention of `super` in the class body, and with it the cell.

The upshot is that the property returns to exactly the code master has,
byte for byte -- the only line this commit removes from mode/locals.py is
the `super().__init_subclass__()` call.  Everything else is additive.

The guard is now the single root invariant -- the class body must have no
`__class__` cellvar -- rather than the two derived rules the previous
commit asserted, which were guarding a workaround that is now gone.
Verified to fail when `super()` is inlined back into `__init_subclass__`.
Also checked that plain and `source=`-parameterised subclassing still work,
since the helper now carries that call.

Still not verified on PyPy directly: the sandbox proxy blocks
downloads.python.org and pypy.org.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjoV6PsbmL1fGyDyB9FwfD
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.

2 participants