I hit this on playwright microsoft/playwright-python#3129
❯ uv run --python 3.14t --no-project --script freethread_switch_deadlock.py
python=3.14.6 gil_enabled=False greenlet=3.5.3
Timeout (0:00:10)!
Thread 0x0000752f8ef5a740 [python3] (most recent call first):
File "/home/guru/Desktop/greenlet/freethread_switch_deadlock.py", line 57 in in_child_fiber
#!/usr/bin/env -S uv run --python 3.14t --script
# /// script
# requires-python = ">=3.14.6"
# dependencies = ["greenlet==3.5.3"]
# ///
"""greenlet deadlocks on free-threaded CPython when a switch happens while a
``PyCriticalSection`` is held.
On a free-threaded build (``python3.14t``) the runtime guards many objects with
per-object locks taken through ``Py_BEGIN_CRITICAL_SECTION``. Those sections are
stack-scoped and tracked per thread in ``tstate->critical_section``. asyncio's C
``Task.__step`` holds such a section on the *task* for the whole step -- that is,
across the Python code the coroutine runs.
If that Python code switches to another greenlet and the greenlet touches the
same task, it needs the very lock the switched-away fiber is still holding.
greenlet swaps C stacks but, before the fix, left those locks held, so the
second fiber blocks forever:
Task.__step # holds a critical section on `task`
-> coro runs -> greenlet.switch() into a child fiber
-> task.add_done_callback(...) # wants task's lock -> deadlock
This is the mechanism behind Playwright's sync API hanging under free-threading,
but it needs no third-party code to reproduce. A regular (GIL-enabled) build has
no critical sections, so the switch is harmless and the script prints OK.
Run it with a released greenlet to see the hang:
uv run --python 3.14t --with greenlet==3.5.3 freethread_switch_deadlock.py
Expected on an affected build: the watchdog fires at ~10s, dumps a stack whose
innermost frame is ``add_done_callback`` inside ``in_child_fiber``, and exits 1.
Fixed build (or any GIL-enabled build): prints OK and exits 0.
"""
import asyncio
import faulthandler
import sys
import greenlet
# The deadlock is immediate when present, so a short watchdog is plenty; it
# dumps the wedged stack and hard-exits instead of hanging forever.
faulthandler.dump_traceback_later(10, exit=True)
async def main() -> None:
gil = getattr(sys, "_is_gil_enabled", lambda: True)()
print(f"python={sys.version.split()[0]} gil_enabled={gil} "
f"greenlet={greenlet.__version__}", flush=True)
task = asyncio.current_task() # its running __step holds a lock on `task`
def in_child_fiber() -> None:
# We are on a fresh C stack now, but the task's lock is still held by
# the fiber we switched away from. Before the fix this never returns.
task.add_done_callback(lambda _: None)
task.remove_done_callback(lambda _: None)
greenlet.greenlet(in_child_fiber).switch()
asyncio.run(main())
faulthandler.cancel_dump_traceback_later()
print("OK: switched greenlets without deadlocking on the held critical section",
flush=True)
I hit this on playwright microsoft/playwright-python#3129