Skip to content

fix: pytester no longer destroys modules owning process global state - #14850

Open
RonnyPfannschmidt wants to merge 1 commit into
pytest-dev:mainfrom
RonnyPfannschmidt:fix/pytester-preserved-modules
Open

RonnyPfannschmidt wants to merge 1 commit into
pytest-dev:mainfrom
RonnyPfannschmidt:fix/pytester-preserved-modules

Conversation

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

Closes #14841.

Pytester restores its sys.modules snapshot on teardown, which destroyed modules that had installed process global state while being imported. The state outlives the module object, and the next import installs it a second time from a second module instance — for multiprocessing that means an orphaned resource tracker process whose bookkeeping no longer matches the live one, which fails loudly at interpreter shutdown.

asyncio, concurrent, execnet, multiprocessing, random and rlcompleter are now preserved next to zope and readline, each with the evidence in a comment. Matching follows package boundaries instead of a name prefix, so zopelicious is no longer preserved by the zope entry — and the check is a single set lookup, ~10x cheaper on a path that runs once per module in sys.modules per test.

scripts/check-preserved-modules.py is where the list comes from. It probes each stdlib module (plus extras such as execnet) in its own subprocess: records atexit/os.register_at_fork/threading._register_atexit registrations with caller attribution, imports the module, replays the snapshot restore, and reports what was left behind. --python audits another interpreter, --check exits non-zero on anything uncovered. A full scan takes ~6s; the list above is the union over 3.10, 3.12, 3.13 and 3.14 on Linux.

It cannot see state installed by C extension modules, or state installed lazily on first use — the tracker process itself is only found because the module registers a fork handler while being imported.

🤖 Generated with Claude Code

@pyba-chronographer pyba-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Aug 10, 2026
@RonnyPfannschmidt RonnyPfannschmidt added the backport 9.1.x apply to PRs at any point; backports the changes to the 9.1.x branch label Aug 10, 2026
@RonnyPfannschmidt
RonnyPfannschmidt requested review from bluetech and a lite review from Copilot August 12, 2026 09:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes pytester’s sys.modules snapshot restoration so it no longer drops modules/packages that install process-global state during import (notably multiprocessing.resource_tracker), preventing duplicated global registrations and noisy interpreter-shutdown failures.

Changes:

  • Replaces prefix-based preserve logic with package-boundary matching via a preserved top-level package set in Pytester.
  • Expands the preserved set to cover additional stdlib/related packages known to install process-global state.
  • Adds an audit script (scripts/check-preserved-modules.py) plus a dedicated tox env to maintain/validate the preserved list, and adds/extends tests + changelog entry.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tox.ini Adds a tox environment to run the preserved-modules audit script.
testing/test_pytester.py Extends snapshot preservation tests and adds a regression test for multiprocessing.resource_tracker surviving teardown.
src/_pytest/pytester.py Introduces a preserved top-level package set and a package-boundary preservation predicate used by SysModulesSnapshot.
scripts/check-preserved-modules.py New maintenance tool to probe modules in subprocesses and report which ones leave process-global state behind after a simulated snapshot-restore.
changelog/14841.bugfix.rst Documents the bugfix and the new audit tooling.
Suppressed comments (1)

scripts/check-preserved-modules.py:149

  • Same issue as above: the restore/dangling scan assumes every sys.modules value is a module and calls vars(module). If any entry is None (or otherwise non-module), the script will crash; skip non-modules here too.
    for modname, module in list(sys.modules.items()):
        for key, value in list(vars(module).items()):

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/check-preserved-modules.py
Comment thread scripts/check-preserved-modules.py
Comment thread changelog/14841.bugfix.rst

@bluetech bluetech left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code changes LGTM.

But I'm not sure about adding the script, I don't know if it's worth it to maintain these 500 lines for an obscure thing like this. Maybe just stick it in the issue for reference if we ever need to run it again?


Such a module was re-imported as a second, independent copy, while the state the first copy installed -- ``atexit`` handlers, :func:`os.register_at_fork` handlers, and in the case of :mod:`multiprocessing` a resource tracker server process -- stayed behind with nothing referring to it. The most visible symptom were ``multiprocessing.resource_tracker`` tracebacks written to stderr at interpreter shutdown.

``asyncio``, ``concurrent``, ``execnet``, ``multiprocessing``, ``random`` and ``rlcompleter`` are now preserved next to the ``readline`` and ``zope`` entries pytest already had. The list is audited by the new ``scripts/check-preserved-modules.py``, which probes the standard library for modules that cannot be dropped safely.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would remove the note about the script (not user-facing)

Suggested change
``asyncio``, ``concurrent``, ``execnet``, ``multiprocessing``, ``random`` and ``rlcompleter`` are now preserved next to the ``readline`` and ``zope`` entries pytest already had. The list is audited by the new ``scripts/check-preserved-modules.py``, which probes the standard library for modules that cannot be dropped safely.
``asyncio``, ``concurrent``, ``execnet``, ``multiprocessing``, ``random`` and ``rlcompleter`` are now preserved next to the ``readline`` and ``zope`` entries pytest already had.


``asyncio``, ``concurrent``, ``execnet``, ``multiprocessing``, ``random`` and ``rlcompleter`` are now preserved next to the ``readline`` and ``zope`` entries pytest already had. The list is audited by the new ``scripts/check-preserved-modules.py``, which probes the standard library for modules that cannot be dropped safely.

Preserving now follows package boundaries instead of matching a plain name prefix, so an unrelated top level module such as ``zopelicious`` is no longer preserved by the ``zope`` entry.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would remove this as well, kinda doubt anyone would be affected by this...

@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix/pytester-preserved-modules branch from e98d4f7 to 4482a05 Compare September 15, 2026 11:34
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix/pytester-preserved-modules branch from 4482a05 to 1001c8e Compare September 23, 2026 07:00
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix/pytester-preserved-modules branch from 1001c8e to e28f18e Compare October 4, 2026 13:06
@MaciejZiel

Copy link
Copy Markdown

I've got a greenlet example of the C extension case from the description.

My plugin finds call sites under SQLAlchemy's AsyncSession. Since SQLAlchemy 2.1 imports greenlet lazily, I often get the first import inside an inner runpytest_inprocess() call.

After the run, greenlet is gone from sys.modules. But the inner code still has a reference to it. On the next import I get a different module object, with the same C type inside it:

import sys

pytest_plugins = ["pytester"]


def test_greenlet_reimported_after_inline_run(pytester):
    pytester.makepyfile(
        """
        import sys

        def test_inner():
            import greenlet
            sys.inner_greenlet = greenlet
        """
    )
    assert "greenlet" not in sys.modules
    pytester.runpytest_inprocess().assert_outcomes(passed=1)

    print("still in sys.modules:", "greenlet" in sys.modules)  # False
    import greenlet

    print("same module object:", greenlet is sys.inner_greenlet)  # False
    print("same C type:", greenlet.greenlet is sys.inner_greenlet.greenlet)  # True

pytest 9.1.1, greenlet 3.5.6, Python 3.12.

So isinstance still works. Looking up the module in sys.modules is where things get awkward: it isn't the one the inner code kept. State stored on the Python module can end up split across the two objects.

I got around it by importing greenlet at the top of the outer test file.

Would a note in the Pytester / runpytest_inprocess() docs make sense? A preserve list can't account for every third-party extension. The note could suggest importing it in the outer test, or using runpytest_subprocess() instead.

I can put together that docs change after this PR lands.

@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix/pytester-preserved-modules branch from e28f18e to aa69b90 Compare October 11, 2026 07:26
Restoring the sys.modules snapshot orphaned atexit and at-fork handlers, and
multiprocessing's resource tracker process, while a re-import installed a
second copy of the same state.

Preserve asyncio, concurrent, execnet, multiprocessing, random and rlcompleter
next to zope and readline, matched per package rather than by name prefix.
scripts/check-preserved-modules.py audits the list against the stdlib.

Closes pytest-dev#14841

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix/pytester-preserved-modules branch from aa69b90 to be6f4aa Compare October 14, 2026 20:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport 9.1.x apply to PRs at any point; backports the changes to the 9.1.x branch bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants