From abd738f2ae72fd5d12ff2b0f8bcec2011b927aed Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 00:17:48 -0400 Subject: [PATCH 1/3] gh-159437: Skip unnecessary GC finalizer scans --- Lib/test/test_gc.py | 103 +++++++++++++ ...-10-15-23-43-15.gh-issue-159437.LHuMLA.rst | 3 + Python/gc.c | 143 ++++++++++++------ 3 files changed, 204 insertions(+), 45 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-23-43-15.gh-issue-159437.LHuMLA.rst diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 919100f2d5ce04..7c17879c8317e2 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -330,6 +330,7 @@ def f(): self.assertEqual(gc.collect(), 1) def test_saveall(self): + self.addCleanup(gc.collect) # Verify that cyclic garbage like lists show up in gc.garbage if the # SAVEALL option is enabled. @@ -352,6 +353,31 @@ def test_saveall(self): self.assertEqual(len(gc.garbage), 1) obj = gc.garbage.pop() self.assertEqual(id(obj), id_L) + obj.clear() + + # A no-op generator finalizer must still set the finalized marker. + gen = (item for item in ()) + next(gen, None) + self.assertTrue(gc.is_tracked(gen)) + self.assertIsNone(gen.gi_frame) + self.assertFalse(gc.is_finalized(gen)) + L = [gen] + L.append(L) + id_L = id(L) + del gen + + try: + gc.set_debug(gc.DEBUG_SAVEALL) + del L + gc.collect() + saved = next(obj for obj in gc.garbage if id(obj) == id_L) + self.assertTrue(gc.is_finalized(saved[0])) + finally: + gc.set_debug(debug) + for obj in gc.garbage: + if id(obj) == id_L: + obj.clear() + gc.garbage.clear() def test_del(self): # __del__ methods can trigger collection, make this to happen @@ -658,6 +684,83 @@ def __del__(self): lazarus = storage.pop() self.assertTrue(gc.is_finalized(lazarus)) + def test_weakref_callback_installs_finalizer(self): + """A callback can make a finalizer eligible before the object is cleared.""" + self.addCleanup(gc.collect) + events = [] + + class C: + """A self-cycle whose weakref callback installs its finalizer.""" + __slots__ = ("cycle", "__weakref__") + + def finalize(obj): + """Record whether finalization sees the intact cycle.""" + events.append(("finalizer", getattr(obj, "cycle", None) is obj)) + + def callback(reference): + """Install the finalizer after observing the cleared weakref.""" + events.append(("callback", reference() is None)) + C.__del__ = finalize + + obj = C() + obj.cycle = obj + reference = weakref.ref(obj, callback) + gc.collect() + del obj + + self.assertEqual(gc.collect(), 1) + self.assertEqual(events, [("callback", True), ("finalizer", True)]) + self.assertIsNone(reference()) + + @requires_gil_enabled("tests the generation-list collector") + def test_debug_output_resurrects_without_callbacks(self): + """Debug output can resurrect garbage without pending callbacks.""" + was_enabled = gc.isenabled() + gc.disable() + self.addCleanup(gc.enable if was_enabled else gc.disable) + self.addCleanup(gc.collect) + resurrected = [] + + class C: + """An object with a cycle and no finalizer.""" + __slots__ = ("cycle", "__weakref__") + + class Stderr: + """Retain the target when collection reports it as collectable.""" + + def write(self, text): + """Retain the target on collectable debug output. + + text: Debug text written by the collector. + Returns: Number of characters accepted. + """ + if text.startswith("gc: collectable") and not resurrected: + target = reference() + if target is not None: + resurrected.append(target) + return len(text) + + writer = Stderr() + gc.collect() + obj = C() + obj.cycle = obj + reference = weakref.ref(obj) + del obj + + debug = gc.get_debug() + try: + with unittest.mock.patch.object(sys, "stderr", writer): + gc.set_debug(gc.DEBUG_COLLECTABLE) + self.assertEqual(gc.collect(), 0) + self.assertEqual(len(resurrected), 1) + self.assertIs(reference(), resurrected[0]) + self.assertIs(resurrected[0].cycle, resurrected[0]) + finally: + gc.set_debug(debug) + for obj in resurrected: + obj.cycle = None + resurrected.clear() + def test_bug1055820b(self): # Corresponds to temp2b.py in the bug report. diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-23-43-15.gh-issue-159437.LHuMLA.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-23-43-15.gh-issue-159437.LHuMLA.rst new file mode 100644 index 00000000000000..0052c33ea970de --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-23-43-15.gh-issue-159437.LHuMLA.rst @@ -0,0 +1,3 @@ +With the GIL enabled, garbage collection skips the finalization scan when no +finalizers or weakref callbacks are pending and debug output for collectable +objects is disabled. diff --git a/Python/gc.c b/Python/gc.c index 5dc79e20191ebc..e7b6ba2f4dad4a 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -756,7 +756,7 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers) } } -/* Handle weakref callbacks. Note that it's possible for such weakrefs to be +/* Prepare weakref callbacks. Note that it's possible for such weakrefs to be * outside the unreachable set -- indeed, those are precisely the weakrefs * whose callbacks must be invoked. See gc_weakref.txt for overview & some * details. @@ -794,17 +794,20 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers) * or missing `tp_traverse` methods. When that object goes away, the callback * for weakref can be executed and that could reveal unreachable objects to * Python-level code. See bpo-38006 as an example bug. + * + * Arguments: + * - `unreachable`: normalized collecting list to inspect. + * - `wrcb_to_call`: initialized list receiving weakrefs whose callbacks are safe to invoke. + * + * Return whether any finalizer is pending or a weakref callback is queued. + * This function does not invoke callbacks. */ -static int -handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, - bool *needs_resurrection_check) +Py_NO_INLINE static bool +prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call) { PyGC_Head *gc; - PyGC_Head wrcb_to_call; /* weakrefs with callbacks to call */ PyGC_Head *next; - int num_freed = 0; - - gc_list_init(&wrcb_to_call); + bool has_pending_finalizer = false; /* Find all weakrefs with callbacks and move into `wrcb_to_call` if the * callback needs to be invoked. We make another pass over wrcb_to_call, @@ -814,9 +817,13 @@ handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, PyWeakReference **wrlist; PyObject *op = FROM_GC(gc); + PyTypeObject *type = Py_TYPE(op); + if (type->tp_finalize != NULL && !has_pending_finalizer) { + has_pending_finalizer = !_PyGC_FINALIZED(op); + } next = GC_NEXT(gc); - if (! _PyType_SUPPORTS_WEAKREFS(Py_TYPE(op))) { + if (! _PyType_SUPPORTS_WEAKREFS(type)) { continue; } @@ -891,21 +898,35 @@ handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, PyGC_Head *wrasgc = AS_GC((PyObject *)wr); // wrasgc is reachable, but next isn't, so they can't be the same _PyObject_ASSERT((PyObject *)wr, wrasgc != next); - gc_list_move(wrasgc, &wrcb_to_call); + gc_list_move(wrasgc, wrcb_to_call); } } + return has_pending_finalizer || !gc_list_is_empty(wrcb_to_call); +} + +/* Invoke queued weakref callbacks and move surviving weakrefs to `old`. + * + * Arguments: + * - `wrcb_to_call`: initialized queue to drain, with a temporary reference to each weakref. + * - `old`: generation receiving weakrefs that survive callback invocation. + * + * Return the number of weakrefs reclaimed while callbacks ran. + */ +static int +invoke_weakref_callbacks(PyGC_Head *wrcb_to_call, PyGC_Head *old) +{ + PyGC_Head *gc; + int num_freed = 0; + /* Invoke the callbacks we decided to honor. It's safe to invoke them * because they can't reference unreachable objects. */ - if (!*needs_resurrection_check) { - *needs_resurrection_check = !gc_list_is_empty(&wrcb_to_call); - } - while (! gc_list_is_empty(&wrcb_to_call)) { + while (! gc_list_is_empty(wrcb_to_call)) { PyObject *temp; PyObject *callback; - gc = (PyGC_Head*)wrcb_to_call._gc_next; + gc = (PyGC_Head*)wrcb_to_call->_gc_next; PyObject *op = FROM_GC(gc); _PyObject_ASSERT(op, PyWeakref_Check(op)); PyWeakReference *wr = (PyWeakReference *)op; @@ -934,7 +955,7 @@ handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, * ours). */ Py_DECREF(op); - if (wrcb_to_call._gc_next == (uintptr_t)gc) { + if (wrcb_to_call->_gc_next == (uintptr_t)gc) { /* object is still alive -- move it */ gc_list_move(gc, old); } @@ -948,7 +969,7 @@ handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, /* Clear all weakrefs to unreachable objects. When this returns, no object in * `unreachable` is weakly referenced anymore. See the comments above - * handle_weakref_callbacks() for why these weakrefs need to be cleared. + * prepare_callbacks() for why these weakrefs need to be cleared. */ static void clear_weakrefs(PyGC_Head *unreachable) @@ -964,7 +985,7 @@ clear_weakrefs(PyGC_Head *unreachable) if (PyWeakref_Check(op)) { /* A weakref inside the unreachable set is always cleared. See - * the comments above handle_weakref_callbacks() for why these + * the comments above prepare_callbacks() for why these * must be cleared. */ _PyWeakref_ClearRef((PyWeakReference *)op); @@ -1036,13 +1057,14 @@ handle_legacy_finalizers(PyThreadState *tstate, /* Run first-time finalizers (if any) on all the objects in collectable. * Note that this may remove some (or even all) of the objects from the * list, due to refcounts falling to 0. + * Return whether a finalizer may have run Python. */ -static void -finalize_garbage(PyThreadState *tstate, PyGC_Head *collectable, - bool *needs_resurrection_check) +static bool +finalize_garbage(PyThreadState *tstate, PyGC_Head *collectable) { destructor finalize; PyGC_Head seen; + bool needs_resurrection_check = false; /* While we're going through the loop, `finalize(op)` may cause op, or * other objects, to be reclaimed via refcounts falling to zero. So @@ -1062,7 +1084,7 @@ finalize_garbage(PyThreadState *tstate, PyGC_Head *collectable, (finalize = Py_TYPE(op)->tp_finalize) != NULL) { if (!_PyGen_FinalizerIsNoop(op)) { - *needs_resurrection_check = true; + needs_resurrection_check = true; } _PyGC_SET_FINALIZED(op); Py_INCREF(op); @@ -1072,6 +1094,7 @@ finalize_garbage(PyThreadState *tstate, PyGC_Head *collectable, } } gc_list_merge(&seen, collectable); + return needs_resurrection_check; } /* Break reference cycles by clearing the containers involved. This is @@ -1253,6 +1276,43 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, } +/* Invoke weakref callbacks and finalizers, then handle possible resurrection. + * + * Arguments: + * - `tstate`: the thread state used when invoking finalizers. + * - `collectable`: normalized collecting list whose finalizers may run. + * - `still_unreachable`: distinct, uninitialized list head for remaining garbage. + * - `old`: generation receiving surviving weakrefs and resurrected objects. + * - `wrcb_to_call`: initialized, possibly empty list of callback-bearing weakrefs to drain. + * - `collected_count`: count incremented by weakrefs reclaimed while callbacks run. + * - `needs_resurrection_check`: whether earlier work may have run Python. + * + * Return whether resurrection analysis ran. If true, `collectable` must not be + * used again and `still_unreachable` contains the remaining garbage. Otherwise, + * `collectable` remains selected and `still_unreachable` is untouched. + */ +Py_NO_INLINE static bool +handle_callbacks(PyThreadState *tstate, PyGC_Head *collectable, + PyGC_Head *still_unreachable, PyGC_Head *old, + PyGC_Head *wrcb_to_call, Py_ssize_t *collected_count, + bool needs_resurrection_check) +{ + bool weakref_callbacks_invoked = !gc_list_is_empty(wrcb_to_call); + *collected_count += invoke_weakref_callbacks(wrcb_to_call, old); + needs_resurrection_check |= weakref_callbacks_invoked; + validate_list(old, collecting_clear_unreachable_clear); + validate_list(collectable, collecting_set_unreachable_clear); + + // Weakref callbacks can make finalizers newly eligible after the initial + // scan, so we must recheck even when we previously found no pending finalizers. + needs_resurrection_check |= finalize_garbage(tstate, collectable); + if (needs_resurrection_check) { + handle_resurrected_objects(collectable, still_unreachable, old); + } + return needs_resurrection_check; +} + + /* Invoke progress callbacks to notify clients that garbage collection * is starting or stopping */ @@ -1563,28 +1623,21 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) } } - /* Clear weakrefs and invoke callbacks as necessary. */ - stats.collected += handle_weakref_callbacks(&unreachable, old, - &needs_resurrection_check); - validate_list(old, collecting_clear_unreachable_clear); - validate_list(&unreachable, collecting_set_unreachable_clear); - - /* Call tp_finalize on objects which have one. */ - finalize_garbage(tstate, &unreachable, &needs_resurrection_check); + PyGC_Head wrcb_to_call; + gc_list_init(&wrcb_to_call); + bool has_pending_callbacks = prepare_callbacks(&unreachable, &wrcb_to_call); + has_pending_callbacks |= needs_resurrection_check; - /* Handle any objects that may have resurrected after the call - * to 'finalize_garbage' and continue the collection with the - * objects that are still unreachable */ PyGC_Head final_unreachable; - if (needs_resurrection_check) { - handle_resurrected_objects(&unreachable, &final_unreachable, old); - } - else { - /* No Python code ran during finalization, so the original - * unreachable set is still valid. */ - gc_list_init(&final_unreachable); - gc_list_merge(&unreachable, &final_unreachable); + PyGC_Head *collectable = &unreachable; + if (has_pending_callbacks) { + bool resurrection_checked = handle_callbacks(tstate, collectable, &final_unreachable, old, &wrcb_to_call, &stats.collected, needs_resurrection_check); + if (resurrection_checked) { + collectable = &final_unreachable; + } } + validate_list(old, collecting_clear_unreachable_clear); + validate_list(collectable, collecting_set_unreachable_clear); /* Clear weakrefs to objects in the unreachable set. No Python-level * code must be allowed to access those unreachable objects. During @@ -1592,14 +1645,14 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) * and create new weakrefs. If those weakrefs were not cleared, they * could reveal unreachable objects. Callbacks are not executed. */ - clear_weakrefs(&final_unreachable); + clear_weakrefs(collectable); - /* Call tp_clear on objects in the final_unreachable set. This will cause + /* Call tp_clear on objects in the collectable set. This will cause * the reference cycles to be broken. It may also cause some objects * in finalizers to be freed. */ - stats.collected += gc_list_size(&final_unreachable); - delete_garbage(tstate, gcstate, &final_unreachable, old); + stats.collected += gc_list_size(collectable); + delete_garbage(tstate, gcstate, collectable, old); /* Collect statistics on uncollectable objects found and print * debugging information. */ From 29cee063425c5a198771e2f8120ff28f691f15fa Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 00:50:58 -0400 Subject: [PATCH 2/3] gh-159437: Count garbage while clearing weakrefs --- ...6-10-14-21-44-05.gh-issue-159437.count1.rst | 1 + Python/gc.c | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-21-44-05.gh-issue-159437.count1.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-21-44-05.gh-issue-159437.count1.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-21-44-05.gh-issue-159437.count1.rst new file mode 100644 index 00000000000000..a98b6aee7ef4ff --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-21-44-05.gh-issue-159437.count1.rst @@ -0,0 +1 @@ +Count collectable objects while clearing weak references, avoiding a separate pass during garbage collection. diff --git a/Python/gc.c b/Python/gc.c index e7b6ba2f4dad4a..dd6d052b9fc8f6 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -967,21 +967,26 @@ invoke_weakref_callbacks(PyGC_Head *wrcb_to_call, PyGC_Head *old) return num_freed; } -/* Clear all weakrefs to unreachable objects. When this returns, no object in - * `unreachable` is weakly referenced anymore. See the comments above - * prepare_callbacks() for why these weakrefs need to be cleared. +/* Clear all weakrefs to unreachable objects without invoking callbacks. + * When this returns, no object in `unreachable` is weakly referenced anymore. + * See the comments above prepare_callbacks() for why these weakrefs need to be cleared. + * + * `unreachable`: Final collectable list; membership is unchanged. + * Return: Number of objects in `unreachable`. */ -static void +static Py_ssize_t clear_weakrefs(PyGC_Head *unreachable) { PyGC_Head *gc; PyGC_Head *next; + Py_ssize_t count = 0; for (gc = GC_NEXT(unreachable); gc != unreachable; gc = next) { PyWeakReference **wrlist; PyObject *op = FROM_GC(gc); next = GC_NEXT(gc); + count++; if (PyWeakref_Check(op)) { /* A weakref inside the unreachable set is always cleared. See @@ -1015,6 +1020,8 @@ clear_weakrefs(PyGC_Head *unreachable) _PyObject_ASSERT((PyObject *)wr, wr->wr_object == Py_None); } } + + return count; } static void @@ -1645,13 +1652,12 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) * and create new weakrefs. If those weakrefs were not cleared, they * could reveal unreachable objects. Callbacks are not executed. */ - clear_weakrefs(collectable); + stats.collected += clear_weakrefs(collectable); /* Call tp_clear on objects in the collectable set. This will cause * the reference cycles to be broken. It may also cause some objects * in finalizers to be freed. */ - stats.collected += gc_list_size(collectable); delete_garbage(tstate, gcstate, collectable, old); /* Collect statistics on uncollectable objects found and print From 8baf990165a160d7bfab91d29b282222129a1e10 Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 00:58:00 -0400 Subject: [PATCH 3/3] gh-159437: Skip unnecessary late weakref cleanup Count the stable unreachable set while preparing callbacks and retain a conservative cleanup flag. Reuse the count only when late weakref clearing has no work, and force fresh cleanup and counting after resurrection analysis. Add a regression for a callback-free weakref observed when clearing a young cycle releases an older finalizer. --- Lib/test/test_gc.py | 30 ++++++++++++++++++ ...-10-16-00-00-00.gh-issue-159437.weakrf.rst | 1 + Python/gc.c | 31 ++++++++++++++++--- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-00-00-00.gh-issue-159437.weakrf.rst diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 7c17879c8317e2..e43ab28eb80717 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -1196,6 +1196,36 @@ def getstats(): gc.enable() + @requires_gil_enabled("tests the generation-list collector") + def test_callback_free_weakref_clear_without_pending_callbacks(self): + """Clear callback-free weakrefs before tp_clear can expose garbage.""" + was_enabled = gc.isenabled() + gc.disable() + self.addCleanup(gc.enable if was_enabled else gc.disable) + self.addCleanup(gc.collect) + observed = [] + self.addCleanup(observed.clear) + + class Observer: + """Inspect a young target when its clearing releases this object.""" + + def __del__(self): + """Record whether the weakref still exposes the target.""" + observed.append(reference()) + + observer = Observer() + gc.collect() + # The older observer does not open the callback path for generation 0. + # Clearing the target releases it, so the weakref must already be dead. + target = C1055820(0) + target.observer = observer + reference = weakref.ref(target) + del observer, target + + self.assertEqual(gc.collect(0), 1) + self.assertEqual(observed, [None]) + self.assertIsNone(reference()) + @unittest.skipIf(ContainerNoGC is None, 'requires ContainerNoGC extension type') def test_trash_weakref_clear(self): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-00-00-00.gh-issue-159437.weakrf.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-00-00-00.gh-issue-159437.weakrf.rst new file mode 100644 index 00000000000000..0dbba4740256a4 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-00-00-00.gh-issue-159437.weakrf.rst @@ -0,0 +1 @@ +Garbage collection in builds with the GIL now reuses the initial garbage count and skips late weakref cleanup when no weakrefs need clearing and the unreachable set remains valid. diff --git a/Python/gc.c b/Python/gc.c index dd6d052b9fc8f6..4ffa572b723ced 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -798,16 +798,22 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers) * Arguments: * - `unreachable`: normalized collecting list to inspect. * - `wrcb_to_call`: initialized list receiving weakrefs whose callbacks are safe to invoke. + * - `collectable_count_out`: receives the number of objects in `unreachable`. + * - `needs_weakref_cleanup_out`: receives whether garbage weakrefs or remaining incoming weakrefs may need cleanup. * * Return whether any finalizer is pending or a weakref callback is queued. - * This function does not invoke callbacks. + * This function does not invoke callbacks. The count and cleanup decision + * remain valid only while the unreachable set and its weakrefs cannot change. */ Py_NO_INLINE static bool -prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call) +prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call, + Py_ssize_t *collectable_count_out, bool *needs_weakref_cleanup_out) { PyGC_Head *gc; PyGC_Head *next; + Py_ssize_t count = 0; bool has_pending_finalizer = false; + bool needs_weakref_cleanup = false; /* Find all weakrefs with callbacks and move into `wrcb_to_call` if the * callback needs to be invoked. We make another pass over wrcb_to_call, @@ -818,11 +824,16 @@ prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call) PyObject *op = FROM_GC(gc); PyTypeObject *type = Py_TYPE(op); + count++; if (type->tp_finalize != NULL && !has_pending_finalizer) { has_pending_finalizer = !_PyGC_FINALIZED(op); } next = GC_NEXT(gc); + // Garbage weakrefs need clearing even if their referents are outside + // this collection (bpo-38006). + needs_weakref_cleanup |= PyWeakref_Check(op); + if (! _PyType_SUPPORTS_WEAKREFS(type)) { continue; } @@ -900,8 +911,12 @@ prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call) _PyObject_ASSERT((PyObject *)wr, wrasgc != next); gc_list_move(wrasgc, wrcb_to_call); } + // Callback-free weakrefs remain linked until finalization. + needs_weakref_cleanup |= *wrlist != NULL; } + *collectable_count_out = count; + *needs_weakref_cleanup_out = needs_weakref_cleanup; return has_pending_finalizer || !gc_list_is_empty(wrcb_to_call); } @@ -1632,7 +1647,9 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) PyGC_Head wrcb_to_call; gc_list_init(&wrcb_to_call); - bool has_pending_callbacks = prepare_callbacks(&unreachable, &wrcb_to_call); + Py_ssize_t collectable_count; + bool needs_weakref_cleanup; + bool has_pending_callbacks = prepare_callbacks(&unreachable, &wrcb_to_call, &collectable_count, &needs_weakref_cleanup); has_pending_callbacks |= needs_resurrection_check; PyGC_Head final_unreachable; @@ -1641,6 +1658,9 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) bool resurrection_checked = handle_callbacks(tstate, collectable, &final_unreachable, old, &wrcb_to_call, &stats.collected, needs_resurrection_check); if (resurrection_checked) { collectable = &final_unreachable; + // Python code may have changed the garbage set or created weakrefs. + // Refresh both cleanup and counting. + needs_weakref_cleanup = true; } } validate_list(old, collecting_clear_unreachable_clear); @@ -1652,7 +1672,10 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) * and create new weakrefs. If those weakrefs were not cleared, they * could reveal unreachable objects. Callbacks are not executed. */ - stats.collected += clear_weakrefs(collectable); + if (needs_weakref_cleanup) { + collectable_count = clear_weakrefs(collectable); + } + stats.collected += collectable_count; /* Call tp_clear on objects in the collectable set. This will cause * the reference cycles to be broken. It may also cause some objects