Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Include/internal/pycore_pystate.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ _Py_IsMainInterpreter(PyInterpreterState *interp)
return (interp == _PyInterpreterState_Main());
}

/* Return whether tracked objects may belong to another interpreter lifetime.
*
* Result:
* - false before any subinterpreter reservation or runtime reinitialization.
* - true after either event, including across Py_Finalize()/Py_Initialize().
*
* Extension-owned objects may outlive the interpreter whose GC list held them,
* so callers must not assume every tracked object belongs to the current
* runtime when this returns true.
*/
PyAPI_FUNC(bool) _PyInterpreterState_MayHaveForeignGcObjects(void);

extern int _Py_IsMainInterpreterFinalizing(PyInterpreterState *interp);

// Export for _interpreters module.
Expand Down
181 changes: 181 additions & 0 deletions Lib/test/test_gc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -1093,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):
Expand Down Expand Up @@ -1289,6 +1422,54 @@ def test_heap_size(self):
del l
self.assertEqual(count, _testinternalcapi.get_tracked_heap_size())

@requires_gil_enabled("tests fused full-collection reference accounting")
@unittest.skipIf(_testinternalcapi is None, "requires _testinternalcapi")
def test_full_collection_preserves_refs_while_untracking_immortal(self):
"""Untracking an immortal preserves adjacent candidates and heap accounting."""
import_module("ctypes")
source = textwrap.dedent("""
import ctypes
import gc
import _testinternalcapi

set_immortal = ctypes.pythonapi._Py_SetImmortal
set_immortal.argtypes = [ctypes.py_object]
set_immortal.restype = None
track = ctypes.pythonapi.PyObject_GC_Track
track.argtypes = [ctypes.py_object]
track.restype = None
may_have_foreign_gc_objects = ctypes.pythonapi._PyInterpreterState_MayHaveForeignGcObjects
may_have_foreign_gc_objects.argtypes = []
may_have_foreign_gc_objects.restype = ctypes.c_bool

gc.disable()
gc.collect(2)
candidates_before = gc.get_stats()[2]["candidates"]
parent = []
middle = []
set_immortal(middle)
track(middle)
child = []
parent.append(child)
child.append(parent)

# Traversing parent initializes child before the intervening
# immortal is removed from the forward chain.
del parent, child
assert gc.get_freeze_count() == 0
assert not may_have_foreign_gc_objects()
# Every tracked object except middle is a candidate here.
expected_candidates = _testinternalcapi.get_tracked_heap_size() - 1
assert gc.collect(2) == 2
assert not gc.is_tracked(middle)
assert gc.get_stats()[2]["candidates"] - candidates_before == expected_candidates

# get_objects() omits the result list itself.
objects = gc.get_objects()
assert _testinternalcapi.get_tracked_heap_size() == len(objects) + 1
""")
assert_python_ok("-c", source)


class GCCallbackTests(unittest.TestCase):
def setUp(self):
Expand Down
10 changes: 8 additions & 2 deletions Lib/test/test_gdb/test_backtrace.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,21 @@ def test_gc(self):
'def bar():\n'
' foo()\n'
'bar()\n')
# Full GIL collections may skip update_refs(). Stop at a visible phase
# after the collector marks collection active.
if support.Py_GIL_DISABLED:

@pablogsal pablogsal Oct 17, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use gc_collect_main regardless of the GIL? The collectors have it and the GDB helper already recognizes it. There's an example in an earlier PR, where I made the same breakpoint change. That would remove this branch.

gc_breakpoint = 'update_refs'
else:
gc_breakpoint = 'prepare_callbacks'
# Verify with "py-bt":
gdb_output = self.get_stack_trace(cmd,
cmds_after_breakpoint=['break update_refs', 'continue', 'py-bt'],
cmds_after_breakpoint=[f'break {gc_breakpoint}', 'continue', 'py-bt'],
)
self.assertIn('Garbage-collecting', gdb_output)

# Verify with "py-bt-full":
gdb_output = self.get_stack_trace(cmd,
cmds_after_breakpoint=['break update_refs', 'continue', 'py-bt-full'],
cmds_after_breakpoint=[f'break {gc_breakpoint}', 'continue', 'py-bt-full'],
)
self.assertIn('Garbage-collecting', gdb_output)

Expand Down
8 changes: 7 additions & 1 deletion Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ CONFIGURE_LDFLAGS= @LDFLAGS@
# command line to append to these values without stomping the pre-set
# values.
PY_CFLAGS= $(BASECFLAGS) $(OPT) $(CONFIGURE_CFLAGS) $(CFLAGS) $(EXTRA_CFLAGS)
PY_CFLAGS_NODIST=$(CONFIGURE_CFLAGS_NODIST) $(CFLAGS_NODIST) -I$(srcdir)/Include/internal -I$(srcdir)/Include/internal/mimalloc
# Configure validates function alignment once, but make-time CFLAGS can still
# select a size-optimized build after configure has completed.
PY_ALIGN_FUNCTIONS_CFLAGS=@FUNCTION_ALIGNMENT_CFLAGS@
ifneq ($(filter -Os -Oz,$(PY_CFLAGS) $(CONFIGURE_CFLAGS_NODIST) $(CFLAGS_NODIST)),)
PY_ALIGN_FUNCTIONS_CFLAGS=
endif
PY_CFLAGS_NODIST=$(CONFIGURE_CFLAGS_NODIST) $(CFLAGS_NODIST) $(PY_ALIGN_FUNCTIONS_CFLAGS) -I$(srcdir)/Include/internal -I$(srcdir)/Include/internal/mimalloc
# Both CPPFLAGS and LDFLAGS need to contain the shell's value for setup.py to
# be able to build extension modules using the directories specified in the
# environment variables
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Request 64-byte function alignment for performance-oriented Linux and macOS builds. This can reduce performance variation caused by changes to the size of preceding functions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Count collectable objects while clearing weak references, avoiding a separate pass during garbage collection.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
With the GIL enabled, combine reference initialization and subtraction in the initial pass of a full collection when the permanent generation is empty and tracked objects cannot belong to another interpreter lifetime.
16 changes: 16 additions & 0 deletions Programs/_testembed.c
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ static int test_repeated_init_and_subinterpreters(void)
for (int i=1; i <= INIT_LOOPS; i++) {
printf("--- Pass %d ---\n", i);
_testembed_initialize();
if (i > 1 && !_PyInterpreterState_MayHaveForeignGcObjects()) {
error("foreign GC object risk was reset");
return 1;
}
mainstate = PyThreadState_Get();

PyEval_ReleaseThread(mainstate);
Expand All @@ -204,6 +208,10 @@ static int test_repeated_init_and_subinterpreters(void)
print_subinterp();
Py_EndInterpreter(substate);
}
if (!_PyInterpreterState_MayHaveForeignGcObjects()) {
error("subinterpreter lifetime did not record foreign GC object risk");
return 1;
}

PyThreadState_Swap(mainstate);
print_subinterp();
Expand Down Expand Up @@ -279,6 +287,14 @@ static int test_repeated_simple_init(void)
fflush(stderr);

_testembed_initialize();
if (i == 1 && _PyInterpreterState_MayHaveForeignGcObjects()) {
error("initial main interpreter may have foreign GC objects");
return 1;
}
if (i > 1 && !_PyInterpreterState_MayHaveForeignGcObjects()) {
error("runtime reinitialization lost foreign GC object risk");
return 1;
}
Py_Finalize();
printf("Finalized\n"); // Give test_embed some output to check
}
Expand Down
Loading
Loading