Skip to content

Commit 4c161ab

Browse files
patchback[bot]bluetechnicoddemus
authored
pytester: avoid unraisableexception gc collects in inline runs to speed up test suite (#13525) (#13526)
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 #12958. Locally the test suite runtime improved dramatically, dropping from 425s to 160s. Fixes #13482. (cherry picked from commit 391324e) Co-authored-by: Ran Benita <[email protected]> Co-authored-by: Bruno Oliveira <[email protected]>
1 parent a86ee09 commit 4c161ab

File tree

4 files changed

+43
-36
lines changed

4 files changed

+43
-36
lines changed

src/_pytest/pytester.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,8 @@ def inline_run(
10921092
Typically we reraise keyboard interrupts from the child run. If
10931093
True, the KeyboardInterrupt exception is captured.
10941094
"""
1095+
from _pytest.unraisableexception import gc_collect_iterations_key
1096+
10951097
# (maybe a cpython bug?) the importlib cache sometimes isn't updated
10961098
# properly between file creation and inline_run (especially if imports
10971099
# are interspersed with file creation)
@@ -1115,12 +1117,16 @@ def inline_run(
11151117

11161118
rec = []
11171119

1118-
class Collect:
1120+
class PytesterHelperPlugin:
11191121
@staticmethod
11201122
def pytest_configure(config: Config) -> None:
11211123
rec.append(self.make_hook_recorder(config.pluginmanager))
11221124

1123-
plugins.append(Collect())
1125+
# The unraisable plugin GC collect slows down inline
1126+
# pytester runs too much.
1127+
config.stash[gc_collect_iterations_key] = 0
1128+
1129+
plugins.append(PytesterHelperPlugin())
11241130
ret = main([str(x) for x in args], plugins=plugins)
11251131
if len(rec) == 1:
11261132
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_config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2175,7 +2175,8 @@ class DummyPlugin:
21752175
plugins = config.invocation_params.plugins
21762176
assert len(plugins) == 2
21772177
assert plugins[0] is plugin
2178-
assert type(plugins[1]).__name__ == "Collect" # installed by pytester.inline_run()
2178+
# Installed by pytester.inline_run().
2179+
assert type(plugins[1]).__name__ == "PytesterHelperPlugin"
21792180

21802181
# args cannot be None
21812182
with pytest.raises(TypeError):

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)