From e2bc38b60a01f665ae3d19c7669689753b3fa035 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Tue, 5 May 2026 10:25:22 -0400 Subject: [PATCH 1/3] unraisableexception: reduce gc_collect_harder default to 1 on CPython The 5-iteration default was borrowed from the Trio project, where it was determined empirically to handle PyPy's object resurrection behavior: on PyPy, objects like coroutines can survive GC rounds because executing their __del__ can resurrect them. On CPython, reference counting frees most objects immediately. One GC pass is sufficient to handle reference cycles, as confirmed by all test_unraisableexception tests passing (including the refcycle variants). Use 1 pass on CPython and retain 5 on PyPy. Signed-off-by: Mike Fiedler --- src/_pytest/unraisableexception.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 0faca36aa00..a7956ff1668 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -86,9 +86,14 @@ def collect_unraisable(config: Config) -> None: def cleanup( *, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object] ) -> None: - # A single collection doesn't necessarily collect everything. - # Constant determined experimentally by the Trio project. - gc_collect_iterations = config.stash.get(gc_collect_iterations_key, 5) + # On PyPy, objects (e.g. coroutines) can survive GC rounds because executing + # their __del__ can resurrect them. The Trio project determined experimentally + # that 5 passes are needed on PyPy to flush everything. On CPython, reference + # counting handles most cleanup immediately, so 1 pass is sufficient. + _default_gc_collect_iterations = 5 if hasattr(sys, "pypy_version_info") else 1 + gc_collect_iterations = config.stash.get( + gc_collect_iterations_key, _default_gc_collect_iterations + ) try: try: gc_collect_harder(gc_collect_iterations) From 429fd57baf6511dc4f519a4ea0b1ab2db1923df2 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Sat, 23 May 2026 18:01:19 -0400 Subject: [PATCH 2/3] optimize: use built-in name check instead of hasattr Using `timeit`, I found a faster call: ```python import sys import timeit def method_hasattr(): return 5 if hasattr(sys, "pypy_version_info") else 1 def method_implementation(): return 5 if sys.implementation.name == "pypy" else 1 time1 = timeit.timeit(method_hasattr, number=10000) time2 = timeit.timeit(method_implementation, number=10000) print(f"Method Hasattr: {time1:.5f} seconds") print(f"Method Implementation: {time2:.5f} seconds") ``` Results: ``` $ PYENV_VERSION=3.14.5 python microbench.py Method Hasattr: 0.00085 seconds Method Implementation: 0.00054 seconds $ PYENV_VERSION=pypy3.11-7.3.22 python microbench.py Method Hasattr: 0.00768 seconds Method Implementation: 0.00070 seconds ``` Refs: https://docs.python.org/3/library/sys.html#sys.implementation Signed-off-by: Mike Fiedler --- src/_pytest/unraisableexception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index a7956ff1668..259f0d8e7f4 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -90,7 +90,7 @@ def cleanup( # their __del__ can resurrect them. The Trio project determined experimentally # that 5 passes are needed on PyPy to flush everything. On CPython, reference # counting handles most cleanup immediately, so 1 pass is sufficient. - _default_gc_collect_iterations = 5 if hasattr(sys, "pypy_version_info") else 1 + _default_gc_collect_iterations = 5 if sys.implementation.name == "pypy" else 1 gc_collect_iterations = config.stash.get( gc_collect_iterations_key, _default_gc_collect_iterations ) From 07cdaabf394927a240dfdb11edf37b06f925ae31 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Sat, 23 May 2026 18:11:27 -0400 Subject: [PATCH 3/3] docs: add changelog Signed-off-by: Mike Fiedler --- changelog/14441.improvement.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/14441.improvement.rst diff --git a/changelog/14441.improvement.rst b/changelog/14441.improvement.rst new file mode 100644 index 00000000000..d37e6466b06 --- /dev/null +++ b/changelog/14441.improvement.rst @@ -0,0 +1 @@ +Reduced the default number of ``gc.collect()`` passes in the ``unraisableexception`` plugin from 5 to 1 on CPython, where reference counting makes a single pass sufficient. PyPy retains 5 passes due to object resurrection via ``__del__``. This can noticeably speed up test suites that trigger many pytester runs.