Skip to content

pytester's sys.modules snapshotting causes improper shutdown of multiprocessing.resource_tracker #14841

Description

@TTsangSC

Description

The pytester fixture takes a snapshot of sys.modules at setup and restores it at teardown, undoing any imports made within the test. While this is normally fine, it may cause resource leaks with the clobbered modules.

One such example is when multiprocessing.resource_tracker, which spins up a server process to monitor multiprocessing resource use. In cases where:

  • multiprocessing.resource_tracker hasn't already been loaded into sys.modules by the time pytester is set up (e.g. by executing some prior multiprocessing code in the process), and is only loaded mid-test, and
  • The test runs multiprocessing code with a parallel workload which errors out,

The server process associated with the copy of multiprocessing.resource_tracker orphaned by pytester's teardown would error out as the parent process exits, emitting hard-to-debug error messages; see Example below.

Obviously there is no one-size-fit-all approach to protecting sys.modules: the current solution already works for most cases,1 and the issue can easily be sidestepped by pre-importing problematic modules. But I wonder if the option to ignore-list certain modules and packages for snapshot restoration would be helpful.

Example

conftest.py
from __future__ import annotations

from collections.abc import Generator
import pytest


pytest_plugins = 'pytester'


def pytest_addoption(parser: pytest.Parser) -> None:
    parser.addini(
        'skip_by_default',
        'skip the tests marked with `@pytest.mark.skip_by_default`',
        type='bool',
        default=True,
    )


def pytest_configure(config: pytest.Config) -> None:
    config.addinivalue_line(
        'markers',
        'skip_by_default: '
        'skip the test by default, '
        'unless the eponymous ini option is set to false',
    )


@pytest.hookimpl(wrapper=True)
def pytest_collection_modifyitems(
    items: list[pytest.Item], config: pytest.Config,
) -> Generator[None, None, Exception | None]:
    """
    Drop the `@pytest.mark.skip_by_default` tests by default.
    """
    result: Exception | None = (yield)

    if not config.getini('skip_by_default'):
        return result

    n = len(items)
    for offset, item in enumerate(reversed(items), 1):
        if any(
            mark.name == 'skip_by_default' for mark in item.iter_markers()
        ):
            del items[n - offset]
    return result
test_demo.py
from __future__ import annotations

import os
import subprocess
import sys
import textwrap
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from pathlib import Path
from typing import Any, Literal

import pytest


def failing_workload() -> int:
    raise RuntimeError('fail')


def passing_workload() -> int:
    return 1


THIS_TEST_SUITE = str(Path(__file__).parent)


fuzz_workload = pytest.mark.parametrize('workload', ['pass', 'fail'])
fuzz_import_mp = pytest.mark.parametrize(
    'import_mp', ['pre-import', 'deferred-import'],
)


@pytest.mark.skip_by_default
@fuzz_workload
def test_mp_workload(
    workload: Literal['pass', 'fail'],
) -> None:
    """
    Dummy test using `multiprocessing`.
    """
    import multiprocessing

    passing = workload == 'pass'
    if passing:
        ctx: AbstractContextManager[Any] = nullcontext()
        func: Callable[[], int] = passing_workload
    else:
        ctx = pytest.raises(RuntimeError)
        func = failing_workload

    n = 2

    with ctx:
        with multiprocessing.Pool(n) as pool:
            result = pool.starmap(func, [()] * n)

    if passing:
        assert result == [1] * n


@pytest.mark.skip_by_default
@fuzz_workload
@fuzz_import_mp
def test_run_mp_test_in_process(
    request: pytest.FixtureRequest,
    workload: Literal['pass', 'fail'],
    import_mp: Literal['pre-import', 'deferred-import'],
) -> None:
    """
    Dummy test running `test_mp_workload()` in-process with `pytester`.
    """
    if import_mp == 'pre-import':
        import multiprocessing.resource_tracker  # noqa: F401

    # Make sure that `multiprocessing` is imported before the `Pytester`
    # instance is created and the `sys.modules` snapshot is taken
    pytester: pytest.Pytester = request.getfixturevalue('pytester')
    _test_run_mp_test_repeatedly_in_process(pytester, workload, 1)


@fuzz_workload
@fuzz_import_mp
def test_run_mp_test(
    workload: Literal['pass', 'fail'],
    import_mp: Literal['pre-import', 'deferred-import'],
) -> None:
    """
    Test what happens when a test using `multiprocessing` is run
    in-process with `pytester`.
    """
    cmd = [
        sys.executable, '-m', 'pytest',
        '-k', f'test_run_mp_test_in_process and {workload} and {import_mp}',
        '-o', 'skip_by_default=false',
        THIS_TEST_SUITE,
    ]
    proc = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        env={
            **os.environ,
            'COLUMNS': (
                str(int(os.environ['COLUMNS']) - 2)
                if 'COLUMNS' in os.environ else
                '80'
            ),
        },
    )
    try:
        proc.check_returncode()
        assert '1 passed' in proc.stdout
        assert 'resource_tracker' not in proc.stderr
    finally:
        for stream in 'stdout', 'stderr':
            value = getattr(proc, stream)
            print(
                f'Command {stream}:',
                textwrap.indent(value, '  ') if value else '<nil>',
                sep='\n' if value else ' ',
            )


def _test_run_mp_test_repeatedly_in_process(
    pytester: pytest.Pytester,
    workload: Literal['pass', 'fail'],
    n: int,
) -> None:
    runs: list[pytest.RunResult] = []
    for _ in range(n):
        runs.append(pytester.runpytest_inprocess(
            '-k', f'test_mp_workload and {workload}',
            '-o', 'skip_by_default=false',
            THIS_TEST_SUITE,
        ))

    for run in runs:
        run.assert_outcomes(passed=1, skipped=0)

Environment

(.venv)  $ python --version --version
Python 3.14.4 (main, Apr  7 2026, 13:13:20) [Clang 17.0.0 (clang-1700.6.4.2)]
(.venv)  $ python -c "import platform; print(platform.platform())"
macOS-15.6.1-arm64-arm-64bit-Mach-O
(.venv)  $ pip list
Package   Version
--------- -------
iniconfig 2.3.0
packaging 26.3
pip       26.0.1
pluggy    1.6.0
Pygments  2.20.0
pytest    9.1.1

Test output

(.venv)  $ pytest --verbose --tb=short test_demo.py
======================================= test session starts ========================================
platform darwin -- Python 3.14.4, pytest-9.1.1, pluggy-1.6.0 -- [...]/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: [...]
collected 10 items                                                                                 

test_demo.py::test_run_mp_test[pre-import-pass] PASSED                                       [ 25%]
test_demo.py::test_run_mp_test[pre-import-fail] PASSED                                       [ 50%]
test_demo.py::test_run_mp_test[deferred-import-pass] PASSED                                  [ 75%]
test_demo.py::test_run_mp_test[deferred-import-fail] FAILED                                  [100%]

============================================= FAILURES =============================================
______________________________ test_run_mp_test[deferred-import-fail] ______________________________
test_demo.py:112: in test_run_mp_test
    assert 'resource_tracker' not in proc.stderr
E   assert 'resource_tracker' not in "Traceback (...-6t1blhst'\n"
E     
E     'resource_tracker' is contained here:
E       Traceback (most recent call last):
E         File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
E     ?                                                                                                                          ++++++++++++++++
E           cache[rtype].remove(name)
E           ~~~~~~~~~~~~~~~~~~~^^^^^^...
E     
E     ...Full output truncated (26 lines hidden), use '-vv' to show
--------------------------------------- Captured stdout call ---------------------------------------
Command stdout:
  ====================================== test session starts =======================================
  platform darwin -- Python 3.14.4, pytest-9.1.1, pluggy-1.6.0
  rootdir: [...]
  collected 10 items / 9 deselected / 1 selected

  test_demo.py .                                                                             [100%]

  ================================ 1 passed, 9 deselected in 0.12s =================================

Command stderr:
  Traceback (most recent call last):
    File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
      cache[rtype].remove(name)
      ~~~~~~~~~~~~~~~~~~~^^^^^^
  KeyError: '/mp-y_qlg7be'
  Traceback (most recent call last):
    File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
      cache[rtype].remove(name)
      ~~~~~~~~~~~~~~~~~~~^^^^^^
  KeyError: '/mp-aokkgdb7'
  Traceback (most recent call last):
    File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
      cache[rtype].remove(name)
      ~~~~~~~~~~~~~~~~~~~^^^^^^
  KeyError: '/mp-cgk04txe'
  Traceback (most recent call last):
    File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
      cache[rtype].remove(name)
      ~~~~~~~~~~~~~~~~~~~^^^^^^
  KeyError: '/mp-stfjt80a'
  Traceback (most recent call last):
    File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
      cache[rtype].remove(name)
      ~~~~~~~~~~~~~~~~~~~^^^^^^
  KeyError: '/mp-qguy9x0s'
  Traceback (most recent call last):
    File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
      cache[rtype].remove(name)
      ~~~~~~~~~~~~~~~~~~~^^^^^^
  KeyError: '/mp-6t1blhst'

===================================== short test summary info ======================================
FAILED test_demo.py::test_run_mp_test[deferred-import-fail] - assert 'resource_tracker' not in "Traceback (...-6t1blhst'\n"
=================================== 1 failed, 3 passed in 1.01s ====================================

Checklist

  • a detailed description of the bug or problem you are having
  • output of pip list from the virtual environment you are using
  • pytest and operating system versions
  • minimal example if possible

Footnotes

  1. Another perhaps related edge case is when a preexisting package retains references to clobbered copies of its submodules (or object therefrom). For example, I have been burnt by concurrent.futures before (Reverting sys.modules causes pickling issues with concurrent.futures.ProcessPoolWorker pyutils/line_profiler#436), which lazy-imports its concrete Executor subclasses. After reverting sys.modules, the lazy-imported copy of ProcessPoolExecutor from the clobbered copy of concurrent.futures.process would be retained. When attempting to use the now-orphaned ProcessPoolExecutor, because its copy of concurrent.futures.process._process_worker() (the workload passed to <some multiproc context>.Process(target=...)) is now a different object from (the nonexistent) sys.modules['concurrent.futures.process']._process_worker(), a pickling error ensues.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions