From f93af82ef204b718b4e7ffbb514f395831da2d85 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 3 Nov 2026 15:25:11 -0500 Subject: [PATCH] test: release purged pcapkit generations in a long unittest leg (#2750) - typing caches every alias it subscribes, Protocol[...] at class creation and Optional[...] from get_type_hints on an Info annotation, and no purge cleared it. Each cached alias kept a purged generation reachable, and its saved excepthook another, so live generations, the heap and the cost of a negative Mapping subclass check grew from module to module. purge_modules and restore_modules now run typing._cleanups beside _reset_abc_caches; TypingCachesReleasedTests pins both and fails without the change. - PCAPNGTruncatedFileTests kept its sweep, whose failures hold their tracebacks and so the class's import, for the rest of the process. It is dropped by a class cleanup. `util/run_unittest_leg.py protocols --exclude protocols/internet`: peak RSS 1730 -> 1016 MiB, 1319 -> 828 s, 13 -> 5 live generations at the end; the touched modules and the tests/project guards pass under plain unittest. --- tests/_support.py | 26 ++++++++++++++ tests/protocols/misc/test_pcapng_unit.py | 3 ++ tests/test_support_helpers.py | 45 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/tests/_support.py b/tests/_support.py index d5d8fc49ad..38e7c7d0d0 100644 --- a/tests/_support.py +++ b/tests/_support.py @@ -17,6 +17,7 @@ import sys import time import types +import typing import unittest import warnings from typing import Callable, Iterable, Iterator, Optional, TypeVar @@ -601,6 +602,29 @@ class objects, and their creation churns the C-level ``_abc_impl`` caches reset(obj) +def _clear_typing_caches() -> None: + """Empty the :mod:`typing` subscription caches. + + Each import of :mod:`pcapkit` subscribes generics with its own classes -- + ``Protocol[...]`` at class creation, ``Optional[...]`` when + :func:`typing.get_type_hints` resolves an ``Info`` annotation -- and + :mod:`typing` caches those aliases where no purge reaches. An entry naming a + purged class keeps its generation reachable, and through that generation's + ``_previous_excepthook`` the generation whose hook it saved. So in one + plain-unittest process the live generations grow from module to module, and + with them the heap and the cost of every negative + :class:`collections.abc.Mapping` subclass check, which walks the subclasses of + them all (#2750). + + ``typing._cleanups`` holds each cache's ``cache_clear``, which CPython's own + test runner calls before each test module. A runtime without it skips the + clear rather than erroring, as :func:`_reset_abc_caches` does. + + """ + for clear in getattr(typing, '_cleanups', ()): + clear() + + #: Default :data:`sys.modules` prefixes the isolation helpers below cover, and #: the ones :func:`tests.conftest.restore_module_table` puts back after every #: test. Only the package under test: a test that imports a third-party module @@ -689,6 +713,7 @@ def restore_modules(snapshot: 'dict[str, types.ModuleType]', # subclasses and churned the shared ABC caches, and those stale answers # outlive the modules that caused them. _reset_abc_caches() + _clear_typing_caches() def _rebind_parent_attributes(prefixes: 'tuple[str, ...]') -> None: @@ -1066,6 +1091,7 @@ def purge_modules(prefixes: Iterable[str]) -> None: if _under_prefix(name, tuple(prefixes)): sys.modules.pop(name, None) _reset_abc_caches() + _clear_typing_caches() #: Class attribute :func:`reimport_once_per_class` keeps a class's own import in. diff --git a/tests/protocols/misc/test_pcapng_unit.py b/tests/protocols/misc/test_pcapng_unit.py index c801612fa5..4d0f1c8229 100644 --- a/tests/protocols/misc/test_pcapng_unit.py +++ b/tests/protocols/misc/test_pcapng_unit.py @@ -3962,6 +3962,9 @@ class PCAPNGTruncatedFileTests(unittest.TestCase): def setUpClass(cls) -> None: super().setUpClass() cls._swept = None + # The failures keep their tracebacks, and with them this class's pcapkit + # import, so the sweep goes once the class is done (#2750). + cls.addClassCleanup(setattr, cls, '_swept', None) def setUp(self) -> None: reimport_once_per_class(self) diff --git a/tests/test_support_helpers.py b/tests/test_support_helpers.py index 7b31602824..6e160dd983 100644 --- a/tests/test_support_helpers.py +++ b/tests/test_support_helpers.py @@ -56,6 +56,7 @@ from __future__ import annotations import contextlib +import gc import hashlib import importlib import importlib.util @@ -70,8 +71,10 @@ import threading import time import types +import typing import unittest import urllib.error +import weakref from typing import TYPE_CHECKING from unittest import mock @@ -806,6 +809,48 @@ def test_a_name_the_parent_defines_for_itself_is_left_alone(self) -> None: self.assertIs(self.parent.child, own) +@unittest.skipUnless(hasattr(typing, '_cleanups'), 'this typing lists no cache clears') +class TypingCachesReleasedTests(unittest.TestCase): + """A purge and a restore let go of the classes :mod:`typing` has subscribed. + + :mod:`typing` caches an alias such as ``Optional[C]``, so without a clear a + purged generation of :mod:`pcapkit` stays reachable through any cached alias + naming one of its classes, and the live generations grow from module to module + of a plain-unittest leg (#2750). A throwaway class stands in for one, and the + prefix matches nothing, so no real module is purged. + + """ + + PREFIX = 'pcapkit.__support_probe_typing' + + def _subscribe(self) -> 'weakref.ref[type]': + """A weak reference to a class that only :mod:`typing`'s cache holds.""" + probe = type('Probe', (), {}) + alias = typing.Optional[probe] + del alias + ref = weakref.ref(probe) + del probe + gc.collect() + self.assertIsNotNone(ref(), 'typing did not cache the alias, so this case proves nothing') + return ref + + def test_a_purge_releases_a_subscribed_class(self) -> None: + ref = self._subscribe() + + purge_modules([self.PREFIX]) + gc.collect() + + self.assertIsNone(ref()) + + def test_a_restore_releases_a_subscribed_class(self) -> None: + ref = self._subscribe() + + restore_modules(snapshot_modules([self.PREFIX]), [self.PREFIX]) + gc.collect() + + self.assertIsNone(ref()) + + class StandInsDoNotOutliveTheirTestTests(unittest.TestCase): """A test that installs a stand-in leaves :data:`sys.modules` as it found it.