diff --git a/Lib/test/test_free_threading/test_gc.py b/Lib/test/test_free_threading/test_gc.py index b287b51826999c7..8c97b04ed4f71b1 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 000000000000000..94098a0cf5195cb --- /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 6eaeeea8848cdbf..f4f1a7777f35223 100644 --- a/Python/gc_free_threading.c +++ b/Python/gc_free_threading.c @@ -2158,20 +2158,24 @@ 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. - 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); - } - } + // 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); - if (needs_resurrection_check) { + if (needs_resurrection_check || debug_print) { _PyEval_StartTheWorld(interp); + // Print debugging information. + if (debug_print) { + 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);