From b16749cedad95bf69ff18aab57c836ffdaabcb0f Mon Sep 17 00:00:00 2001 From: jg Date: Thu, 5 Nov 2026 21:12:45 +0000 Subject: [PATCH 1/4] Fix deadlock with FT GC debug collecatables print debug printing collectables ran under stop the world in the debug cycle which can deadlock on buffered writer. In debug mode this immediately. We move the debug print to outside of stop the world block inside the `needs_resurrection_check` so the correct cleanup can run but before collection. --- Lib/test/test_free_threading/test_gc.py | 30 +++++++++++++++++++ ...-11-05-21-00-57.gh-issue-161366.ouxAvv.rst | 4 +++ Python/gc_free_threading.c | 12 +++++--- 3 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-11-05-21-00-57.gh-issue-161366.ouxAvv.rst diff --git a/Lib/test/test_free_threading/test_gc.py b/Lib/test/test_free_threading/test_gc.py index b287b51826999c..8c97b04ed4f71b 100644 --- a/Lib/test/test_free_threading/test_gc.py +++ b/Lib/test/test_free_threading/test_gc.py @@ -9,6 +9,7 @@ from test import support from test.support import threading_helper +from test.support.script_helper import assert_python_ok class MyObj: @@ -110,6 +111,35 @@ def run(self): self.assertTrue(C().run()) + def test_debug_collectable_python_stderr(self): + # The DEBUG_COLLECTABLE output is written with sys.stderr.write(), + # which may run Python code or wait for another thread (e.g. for the + # lock of a buffered stream), so it must not be written while the + # world is stopped. Debug builds assert that the world is not stopped + # when Python code runs, so use a sys.stderr written in Python. + code = """if 1: + import gc + import io + import sys + + class Stderr(io.StringIO): + def write(self, s): + return super().write(s) + + sys.stderr = Stderr() + gc.set_debug(gc.DEBUG_COLLECTABLE) + a = [] + a.append(a) + del a + gc.collect() + gc.set_debug(0) + output = sys.stderr.getvalue() + sys.stderr = sys.__stderr__ + print(output) + """ + _, out, _ = assert_python_ok("-c", code) + self.assertRegex(out, b"gc: collectable ") + def test_merge_brc_queue_of_detached_thread(self): # GH-157838: objects queued for merging by a thread that is detached # (blocked in a lock acquire, sleep, etc.) are merged and freed on its diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-11-05-21-00-57.gh-issue-161366.ouxAvv.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-11-05-21-00-57.gh-issue-161366.ouxAvv.rst new file mode 100644 index 00000000000000..94098a0cf5195c --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-11-05-21-00-57.gh-issue-161366.ouxAvv.rst @@ -0,0 +1,4 @@ +Fix a deadlock in the garbage collector of the :term:`free-threaded build` +when :data:`gc.DEBUG_COLLECTABLE` is set and another thread is writing to +:data:`sys.stderr`. The debug output is now written after the other threads +are resumed. diff --git a/Python/gc_free_threading.c b/Python/gc_free_threading.c index 6eaeeea8848cdb..1dff924e34da02 100644 --- a/Python/gc_free_threading.c +++ b/Python/gc_free_threading.c @@ -2161,10 +2161,6 @@ gc_collect_internal(PyInterpreterState *interp, struct collection_state *state, // Print debugging information. if (FT_ATOMIC_LOAD_INT_RELAXED(interp->gc.debug) & _PyGC_DEBUG_COLLECTABLE) { needs_resurrection_check = true; - PyObject *op; - WORKSTACK_FOR_EACH(&state->unreachable, op) { - debug_cycle("collectable", op); - } } // Find weakref callbacks and pending finalizers (but do not call them). @@ -2172,6 +2168,14 @@ gc_collect_internal(PyInterpreterState *interp, struct collection_state *state, if (needs_resurrection_check) { _PyEval_StartTheWorld(interp); + // Print debugging information. + if (FT_ATOMIC_LOAD_INT_RELAXED(interp->gc.debug) & _PyGC_DEBUG_COLLECTABLE) { + PyObject *op; + WORKSTACK_FOR_EACH(&state->unreachable, op) { + debug_cycle("collectable", op); + } + } + // Deallocate any object from the refcount merge step cleanup_worklist(&state->objs_to_decref); From e0a37d64ee26db9980c721e4b1d6a1659e317368 Mon Sep 17 00:00:00 2001 From: jg Date: Thu, 5 Nov 2026 21:24:50 +0000 Subject: [PATCH 2/4] Update comment --- Python/gc_free_threading.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python/gc_free_threading.c b/Python/gc_free_threading.c index 1dff924e34da02..5faacde040a278 100644 --- a/Python/gc_free_threading.c +++ b/Python/gc_free_threading.c @@ -2158,7 +2158,7 @@ gc_collect_internal(PyInterpreterState *interp, struct collection_state *state, // Deallocations after the refcount merge may run Python code. bool needs_resurrection_check = state->objs_to_decref.head != 0; - // Print debugging information. + // Force resurrection code path which will run the debug print if (FT_ATOMIC_LOAD_INT_RELAXED(interp->gc.debug) & _PyGC_DEBUG_COLLECTABLE) { needs_resurrection_check = true; } From 45f639dedacfa98200abd9eab0ea2eecf0dd50cd Mon Sep 17 00:00:00 2001 From: jg Date: Thu, 5 Nov 2026 23:28:33 +0000 Subject: [PATCH 3/4] Avoid second debug print check --- Python/gc_free_threading.c | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Python/gc_free_threading.c b/Python/gc_free_threading.c index 5faacde040a278..88e05f64c350ec 100644 --- a/Python/gc_free_threading.c +++ b/Python/gc_free_threading.c @@ -2158,18 +2158,19 @@ gc_collect_internal(PyInterpreterState *interp, struct collection_state *state, // Deallocations after the refcount merge may run Python code. bool needs_resurrection_check = state->objs_to_decref.head != 0; - // Force resurrection code path which will run the debug print + bool debug_print = false; if (FT_ATOMIC_LOAD_INT_RELAXED(interp->gc.debug) & _PyGC_DEBUG_COLLECTABLE) { - needs_resurrection_check = true; + // Trigger debug print once we have exited start the world in re + debug_print = true; } // Find weakref callbacks and pending finalizers (but do not call them). bool has_finalizers = prepare_finalization(state, &needs_resurrection_check); - if (needs_resurrection_check) { + if (needs_resurrection_check || debug_print) { _PyEval_StartTheWorld(interp); // Print debugging information. - if (FT_ATOMIC_LOAD_INT_RELAXED(interp->gc.debug) & _PyGC_DEBUG_COLLECTABLE) { + if (debug_print) { PyObject *op; WORKSTACK_FOR_EACH(&state->unreachable, op) { debug_cycle("collectable", op); From f06d2d4e120cb6a9fc382b4463e04c34f51d68b1 Mon Sep 17 00:00:00 2001 From: jg Date: Thu, 5 Nov 2026 23:30:37 +0000 Subject: [PATCH 4/4] Inline check --- Python/gc_free_threading.c | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Python/gc_free_threading.c b/Python/gc_free_threading.c index 88e05f64c350ec..f4f1a7777f3522 100644 --- a/Python/gc_free_threading.c +++ b/Python/gc_free_threading.c @@ -2158,11 +2158,10 @@ gc_collect_internal(PyInterpreterState *interp, struct collection_state *state, // Deallocations after the refcount merge may run Python code. bool needs_resurrection_check = state->objs_to_decref.head != 0; - bool debug_print = false; - if (FT_ATOMIC_LOAD_INT_RELAXED(interp->gc.debug) & _PyGC_DEBUG_COLLECTABLE) { - // Trigger debug print once we have exited start the world in re - debug_print = true; - } + // The debug output is written with sys.stderr.write(), which may run + // Python code, so it is printed below after the world is started. + bool debug_print = (FT_ATOMIC_LOAD_INT_RELAXED(interp->gc.debug) & + _PyGC_DEBUG_COLLECTABLE) != 0; // Find weakref callbacks and pending finalizers (but do not call them). bool has_finalizers = prepare_finalization(state, &needs_resurrection_check);