Skip to content

Commit 4a73ab6

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 4a73ab6

File tree

3 files changed

+40
-35
lines changed

3 files changed

+40
-35
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 & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from __future__ import annotations
22

3-
from collections.abc import Generator
4-
import contextlib
53
import gc
64
import sys
75
from unittest import mock
@@ -229,19 +227,13 @@ def _set_gc_state(enabled: bool) -> bool:
229227
return was_enabled
230228

231229

232-
@contextlib.contextmanager
233-
def _disable_gc() -> Generator[None]:
234-
was_enabled = _set_gc_state(enabled=False)
235-
try:
236-
yield
237-
finally:
238-
_set_gc_state(enabled=was_enabled)
239-
240-
241230
def test_refcycle_unraisable(pytester: Pytester) -> None:
242231
# see: https://github.com/pytest-dev/pytest/issues/10404
243232
pytester.makepyfile(
244233
test_it="""
234+
# Should catch the unraisable exception even if gc is disabled.
235+
import gc; gc.disable()
236+
245237
import pytest
246238
247239
class BrokenDel:
@@ -256,23 +248,22 @@ def test_it():
256248
"""
257249
)
258250

259-
with _disable_gc():
260-
result = pytester.runpytest()
251+
result = pytester.runpytest_subprocess(
252+
"-Wdefault::pytest.PytestUnraisableExceptionWarning"
253+
)
261254

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

265257
result.assert_outcomes(passed=1)
266258
result.stderr.fnmatch_lines("ValueError: del is broken")
267259

268260

269-
@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning")
270261
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
274262
pytester.makepyfile(
275263
test_it="""
264+
# Should catch the unraisable exception even if gc is disabled.
265+
import gc; gc.disable()
266+
276267
import pytest
277268
278269
class BrokenDel:
@@ -287,17 +278,18 @@ def test_it():
287278
"""
288279
)
289280

290-
with _disable_gc():
291-
result = pytester.runpytest("-Werror")
281+
result = pytester.runpytest_subprocess(
282+
"-Werror::pytest.PytestUnraisableExceptionWarning"
283+
)
292284

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

296289
result.assert_outcomes(passed=1)
297290
result.stderr.fnmatch_lines("ValueError: del is broken")
298291

299292

300-
@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning")
301293
def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> None:
302294
# note that the host pytest warning filter is disabled and the pytester
303295
# warning filter applies during config teardown of unraisablehook.
@@ -306,6 +298,9 @@ def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> Non
306298
# the issue
307299
pytester.makepyfile(
308300
test_it="""
301+
# Should catch the unraisable exception even if gc is disabled.
302+
import gc; gc.disable()
303+
309304
import asyncio
310305
import pytest
311306
@@ -318,11 +313,11 @@ def test_scheduler_must_be_created_within_running_loop() -> None:
318313
"""
319314
)
320315

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

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

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

0 commit comments

Comments
 (0)