Skip to content

Commit 0b471ba

Browse files
authored
Merge pull request #519 from ddorian/freethread-switch-critical-section
Fix free-threaded deadlock when switching greenlets holding a critical section
2 parents 74b3c41 + d55914d commit 0b471ba

5 files changed

Lines changed: 81 additions & 2 deletions

File tree

CHANGES.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
3.5.4 (unreleased)
66
==================
77

8-
- Nothing changed yet.
9-
8+
- Fix a deadlock on free-threaded builds when a greenlet switch happened
9+
while a ``PyCriticalSection`` was held -- for example inside asyncio's
10+
``Task.__step``, which holds one on the running task for the duration of
11+
the step. See `PR 519 <https://github.com/python-greenlet/greenlet/pull/519/>`.
12+
Thank to ddorian and Kumar Aditya.
1013

1114
3.5.3 (2026-06-26)
1215
==================

src/greenlet/TGreenlet.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ using greenlet::refs::BorrowedGreenlet;
3939
#endif
4040
#ifdef Py_GIL_DISABLED
4141
# include "internal/pycore_tstate.h"
42+
# include "internal/pycore_critical_section.h"
4243
#endif
4344
#endif
4445

src/greenlet/TPythonState.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,19 @@ void PythonState::operator<<(const PyThreadState *const tstate) noexcept
192192
// ``greenlet.tests.test_greenlet_trash`` tries, but under 3.14,
193193
// at least, fails to do so.
194194
this->delete_later = Py_XNewRef(tstate->delete_later);
195+
#ifdef Py_GIL_DISABLED
196+
// Switching greenlets swaps C stacks, which to the free-threaded runtime is
197+
// the same predicament as detaching the thread: the PyCriticalSection nodes
198+
// chained off tstate->critical_section live on the stack we're leaving, and
199+
// their PyMutexes would stay locked behind our back. The greenlet we switch
200+
// to could then block forever taking one of those same locks -- e.g. an
201+
// asyncio event dispatched onto another fiber re-enters a Task/Future that
202+
// the suspended fiber is mid-step on. So drop the locks here the way
203+
// _PyThreadState_Detach() does and let operator>> re-take them on resume.
204+
if (tstate->critical_section != 0) {
205+
_PyCriticalSection_SuspendAll(const_cast<PyThreadState*>(tstate));
206+
}
207+
#endif
195208
this->critical_section = tstate->critical_section;
196209
#elif GREENLET_PY312
197210
this->trash_delete_nesting = tstate->trash.delete_nesting;
@@ -301,6 +314,16 @@ void PythonState::operator>>(PyThreadState *const tstate) noexcept
301314
Py_CLEAR(this->delete_later);
302315
}
303316
tstate->critical_section = this->critical_section;
317+
#ifdef Py_GIL_DISABLED
318+
// Re-acquire whatever operator<< suspended when this greenlet last yielded.
319+
// A no-op for a greenlet that held no locks, and for a brand-new one whose
320+
// chain starts empty. Mirrors the resume in _PyThreadState_Attach(); note
321+
// _PyCriticalSection_Resume() dereferences the head, so the != 0 guard is
322+
// load-bearing, not just a fast path.
323+
if (tstate->critical_section != 0) {
324+
_PyCriticalSection_Resume(tstate);
325+
}
326+
#endif
304327

305328
#elif GREENLET_PY312
306329
tstate->trash.delete_nesting = this->trash_delete_nesting;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Out-of-process payload for the free-threaded switch/critical-section deadlock.
2+
3+
See ``freethread_switch_deadlock.py`` in the repo root for the full write-up.
4+
Before the fix, a greenlet switch swapped C stacks but left any held
5+
``PyCriticalSection`` locks (tracked in ``tstate->critical_section``) locked.
6+
asyncio's ``Task.__step`` holds such a lock on the running task across the step,
7+
so switching into a child greenlet and touching that task from there blocked
8+
forever. A fixed build (and any GIL-enabled build) prints the sentinel; a
9+
regressed free-threaded build deadlocks, and the watchdog turns that hang into a
10+
non-zero exit so the test fails loudly instead of stalling the whole suite.
11+
"""
12+
import asyncio
13+
import faulthandler
14+
import sys
15+
16+
import greenlet
17+
18+
# The deadlock is immediate when present; the generous timeout is only so a
19+
# genuinely regressed build still exits on the slowest CI, never on a good one.
20+
faulthandler.dump_traceback_later(15, exit=True)
21+
22+
23+
async def main():
24+
task = asyncio.current_task() # its running __step holds a lock on `task`
25+
26+
def in_child_fiber():
27+
# Fresh C stack; the task's lock is still held by the fiber we left.
28+
# A regressed build never returns from this first call.
29+
task.add_done_callback(lambda _: None)
30+
task.remove_done_callback(lambda _: None)
31+
32+
greenlet.greenlet(in_child_fiber).switch()
33+
34+
35+
asyncio.run(main())
36+
faulthandler.cancel_dump_traceback_later()
37+
print("SWITCH CS OK")
38+
sys.exit(0)

src/greenlet/tests/test_greenlet.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,6 +1468,20 @@ def test_reentrant_switch_run_callable_has_del(self):
14681468
output
14691469
)
14701470

1471+
def test_switch_leaves_no_critical_section_held(self):
1472+
# Free-threaded builds take per-object locks via
1473+
# Py_BEGIN_CRITICAL_SECTION, tracked in tstate->critical_section. A
1474+
# switch swaps C stacks, so leaving those locks held strands them on the
1475+
# fiber we left: asyncio's Task.__step holds one on the running task
1476+
# across the step, and touching that task from a child fiber then
1477+
# deadlocked re-taking it. Out of process because a regression is a
1478+
# hang, not a catchable error.
1479+
# (repro: freethread_switch_deadlock.py in the repo root)
1480+
if not RUNNING_ON_FREETHREAD_BUILD:
1481+
self.skipTest("Only free-threaded builds take critical sections")
1482+
output = self.run_script('fail_switch_critical_section.py')
1483+
self.assertIn('SWITCH CS OK', output)
1484+
14711485
class TestModule(TestCase):
14721486

14731487
@unittest.skipUnless(hasattr(sys, '_is_gil_enabled'),

0 commit comments

Comments
 (0)