Skip to content

Commit e83640d

Browse files
bluetechnicoddemus
andcommitted
pytester: avoid unraisableexception gc collects in inline runs to speed up test suite
Because `pytester.runpytest()` executes the full session cycle (including `pytest_unconfigure`), it was calling `gc.collect()` in a loop multiple times—even for small, fast tests. This significantly increased the total test suite runtime. To optimize performance, disable the gc runs in inline pytester runs entirely, matching the behavior before pytest-dev#12958. Locally the test suite runtime improved dramatically, dropping from 425s to 160s. Fixes pytest-dev#13482. Co-authored-by: Bruno Oliveira <[email protected]>
1 parent 336cb91 commit e83640d

File tree

3 files changed

+40
-24
lines changed

3 files changed

+40
-24
lines changed

src/_pytest/pytester.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
from _pytest.reports import CollectReport
6666
from _pytest.reports import TestReport
6767
from _pytest.tmpdir import TempPathFactory
68+
from _pytest.unraisableexception import gc_collect_iterations_key
6869
from _pytest.warning_types import PytestFDWarning
6970

7071

@@ -1115,12 +1116,16 @@ def inline_run(
11151116

11161117
rec = []
11171118

1118-
class Collect:
1119+
class PytesterHelperPlugin:
11191120
@staticmethod
11201121
def pytest_configure(config: Config) -> None:
11211122
rec.append(self.make_hook_recorder(config.pluginmanager))
11221123

1123-
plugins.append(Collect())
1124+
# The unraisable plugin GC collect slows down inline
1125+
# pytester runs too much.
1126+
config.stash[gc_collect_iterations_key] = 0
1127+
1128+
plugins.append(PytesterHelperPlugin())
11241129
ret = main([str(x) for x in args], plugins=plugins)
11251130
if len(rec) == 1:
11261131
reprec = rec.pop()

src/_pytest/unraisableexception.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
from exceptiongroup import ExceptionGroup
2525

2626

27-
def gc_collect_harder() -> None:
28-
# A single collection doesn't necessarily collect everything.
29-
# Constant determined experimentally by the Trio project.
30-
for _ in range(5):
27+
# This is a stash item and not a simple constant to allow pytester to override it.
28+
gc_collect_iterations_key = StashKey[int]()
29+
30+
31+
def gc_collect_harder(iterations: int) -> None:
32+
for _ in range(iterations):
3133
gc.collect()
3234

3335

@@ -84,9 +86,12 @@ def collect_unraisable(config: Config) -> None:
8486
def cleanup(
8587
*, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object]
8688
) -> None:
89+
# A single collection doesn't necessarily collect everything.
90+
# Constant determined experimentally by the Trio project.
91+
gc_collect_iterations = config.stash.get(gc_collect_iterations_key, 5)
8792
try:
8893
try:
89-
gc_collect_harder()
94+
gc_collect_harder(gc_collect_iterations)
9095
collect_unraisable(config)
9196
finally:
9297
sys.unraisablehook = prev_hook

testing/test_unraisableexception.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ def test_refcycle_unraisable(pytester: Pytester) -> None:
242242
# see: https://github.com/pytest-dev/pytest/issues/10404
243243
pytester.makepyfile(
244244
test_it="""
245+
# Should catch the unraisable exception even if gc is disabled.
246+
import gc; gc.disable()
247+
245248
import pytest
246249
247250
class BrokenDel:
@@ -256,23 +259,22 @@ def test_it():
256259
"""
257260
)
258261

259-
with _disable_gc():
260-
result = pytester.runpytest()
262+
result = pytester.runpytest_subprocess(
263+
"-Wdefault::pytest.PytestUnraisableExceptionWarning"
264+
)
261265

262-
# TODO: should be a test failure or error
263-
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
266+
assert result.ret == 0
264267

265268
result.assert_outcomes(passed=1)
266269
result.stderr.fnmatch_lines("ValueError: del is broken")
267270

268271

269-
@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning")
270272
def test_refcycle_unraisable_warning_filter(pytester: Pytester) -> None:
271-
# note that the host pytest warning filter is disabled and the pytester
272-
# warning filter applies during config teardown of unraisablehook.
273-
# see: https://github.com/pytest-dev/pytest/issues/10404
274273
pytester.makepyfile(
275274
test_it="""
275+
# Should catch the unraisable exception even if gc is disabled.
276+
import gc; gc.disable()
277+
276278
import pytest
277279
278280
class BrokenDel:
@@ -287,17 +289,18 @@ def test_it():
287289
"""
288290
)
289291

290-
with _disable_gc():
291-
result = pytester.runpytest("-Werror")
292+
result = pytester.runpytest_subprocess(
293+
"-Werror::pytest.PytestUnraisableExceptionWarning"
294+
)
292295

293-
# TODO: should be a test failure or error
294-
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
296+
# TODO: Should be a test failure or error. Currently the exception
297+
# propagates all the way to the top resulting in exit code 1.
298+
assert result.ret == 1
295299

296300
result.assert_outcomes(passed=1)
297301
result.stderr.fnmatch_lines("ValueError: del is broken")
298302

299303

300-
@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning")
301304
def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> None:
302305
# note that the host pytest warning filter is disabled and the pytester
303306
# warning filter applies during config teardown of unraisablehook.
@@ -306,6 +309,9 @@ def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> Non
306309
# the issue
307310
pytester.makepyfile(
308311
test_it="""
312+
# Should catch the unraisable exception even if gc is disabled.
313+
import gc; gc.disable()
314+
309315
import asyncio
310316
import pytest
311317
@@ -318,11 +324,11 @@ def test_scheduler_must_be_created_within_running_loop() -> None:
318324
"""
319325
)
320326

321-
with _disable_gc():
322-
result = pytester.runpytest("-Werror")
327+
result = pytester.runpytest_subprocess("-Werror")
323328

324-
# TODO: should be a test failure or error
325-
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
329+
# TODO: Should be a test failure or error. Currently the exception
330+
# propagates all the way to the top resulting in exit code 1.
331+
assert result.ret == 1
326332

327333
result.assert_outcomes(passed=1)
328334
result.stderr.fnmatch_lines("RuntimeWarning: coroutine 'my_task' was never awaited")

0 commit comments

Comments
 (0)