pax_global_header00006660000000000000000000000064152406432570014522gustar00rootroot0000000000000052 comment=be8d511b2e51d0557cb1138cb4e21f7dd9c1235c mne-tools-refleak-be8d511/000077500000000000000000000000001524064325700154615ustar00rootroot00000000000000mne-tools-refleak-be8d511/.coveragerc000066400000000000000000000003201524064325700175750ustar00rootroot00000000000000[run] branch = True source = refleak omit = */setup.py */refleak/conftest.py [report] exclude_lines = pragma: no cover if __name__ == .__main__.: @abstractmethod @abstractclassmethod mne-tools-refleak-be8d511/.git_archival.txt000066400000000000000000000001521524064325700207320ustar00rootroot00000000000000node: be8d511b2e51d0557cb1138cb4e21f7dd9c1235c node-date: 2026-08-17T13:36:47-04:00 describe-name: v0.2.2 mne-tools-refleak-be8d511/.gitattributes000066400000000000000000000000401524064325700203460ustar00rootroot00000000000000.git_archival.txt export-subst mne-tools-refleak-be8d511/.github/000077500000000000000000000000001524064325700170215ustar00rootroot00000000000000mne-tools-refleak-be8d511/.github/dependabot.yml000066400000000000000000000003101524064325700216430ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" time: "12:00" groups: actions: patterns: - "*" mne-tools-refleak-be8d511/.github/release.yaml000066400000000000000000000001621524064325700213240ustar00rootroot00000000000000changelog: exclude: authors: - dependabot[bot] - pre-commit-ci[bot] - github-actions[bot] mne-tools-refleak-be8d511/.github/workflows/000077500000000000000000000000001524064325700210565ustar00rootroot00000000000000mne-tools-refleak-be8d511/.github/workflows/autofix.yml000066400000000000000000000041541524064325700232640ustar00rootroot00000000000000name: autofix.ci on: # yamllint disable-line rule:truthy pull_request: types: [opened, synchronize, reopened] schedule: - cron: '0 0 * * 1' # At 00:00 on every Monday workflow_dispatch: permissions: contents: write jobs: autofix: name: "prek pre-commit" env: # Will use MNE_BOT_TOKEN when running from workflow_dispatch or schedule GITHUB_TOKEN: ${{ secrets.MNE_BOT_TOKEN || secrets.GITHUB_TOKEN }} runs-on: ubuntu-latest defaults: run: shell: bash -el {0} steps: - uses: actions/checkout@v7 with: token: ${{ env.GITHUB_TOKEN}} - name: Do a deeper checkout if: github.event_name != 'pull_request' run: | set -x git fetch origin "${{ github.event.ref }}" git reset --hard "${{ github.event.ref }}" - uses: j178/prek-action@v2.0.6 with: install-only: true - run: prek run --show-diff-on-failure --color=always --all-files if: github.event_name == 'pull_request' - uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a with: commit-message: "mne[bot]: prek run autofix" if: github.event_name == 'pull_request' && failure() - name: Create PR if: github.event_name != 'pull_request' run: | prek auto-update git diff && git status --porcelain if [[ ! $(git status --porcelain) ]]; then echo "No changes to commit" exit 0 fi set -xeo pipefail git config --global user.email "50266005+mne-bot@users.noreply.github.com" git config --global user.name "mne[bot]" BRANCH=prek/$(date '+%Y%m%d') git checkout -b $BRANCH git commit -am "mne[bot]: Update prek" git push origin $BRANCH PR_NUM=$(gh pr create --base main --head $BRANCH --title "Update prek" --body "Created by [GitHub action](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).") echo "Opened https://github.com/${{ github.repository }}/pull/${PR_NUM}" >> $GITHUB_STEP_SUMMARY mne-tools-refleak-be8d511/.github/workflows/automerge.yml000066400000000000000000000013651524064325700235760ustar00rootroot00000000000000name: Bot auto-merge on: pull_request # yamllint disable-line rule:truthy jobs: autobot: permissions: contents: write pull-requests: write runs-on: ubuntu-latest if: (github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'pre-commit-ci[bot]' || github.event.pull_request.user.login == 'github-actions[bot]' || github.event.pull_request.user.login == 'mne-bot' || github.event.pull_request.user.login == 'mne[bot]') && github.repository == 'mne-tools/refleak' steps: - name: Enable auto-merge for bot PRs run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GH_TOKEN: ${{secrets.GITHUB_TOKEN}} mne-tools-refleak-be8d511/.github/workflows/release.yml000066400000000000000000000022671524064325700232300ustar00rootroot00000000000000# Adapted from MNE-BIDS-Pipeline name: Build on: # yamllint disable-line rule:truthy release: types: [published] push: branches: - main pull_request: branches: - main permissions: contents: read jobs: package: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-python@v7 - run: python -m pip install --upgrade pip build twine check-manifest packaging - run: check-manifest -v - run: python -m build --sdist --wheel - run: twine check --strict dist/* - run: | echo "Triggered by: ${{ github.event_name }}" - uses: actions/upload-artifact@v7 with: name: dist path: dist # PyPI on release pypi: needs: package runs-on: ubuntu-latest if: github.event_name == 'release' permissions: id-token: write # for trusted publishing environment: name: pypi url: https://pypi.org/p/refleak steps: - uses: actions/download-artifact@v8 with: name: dist path: dist - run: ls -lR dist - uses: pypa/gh-action-pypi-publish@release/v1 mne-tools-refleak-be8d511/.github/workflows/tests.yml000066400000000000000000000023461524064325700227500ustar00rootroot00000000000000name: 'Tests' concurrency: group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} cancel-in-progress: true on: # yamllint disable-line rule:truthy push: branches: - main pull_request: branches: - main jobs: pytest: name: '${{ matrix.os }} / ${{ matrix.python }}' continue-on-error: true runs-on: ${{ matrix.os }} timeout-minutes: 10 defaults: run: shell: bash -e {0} env: PYTHON_VERSION: '${{ matrix.python }}' strategy: matrix: include: - os: ubuntu-latest python: '3.14' - os: ubuntu-latest python: '3.10' - os: windows-latest python: '3.12' - os: macos-latest python: '3.13' steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python }} - run: python -m pip install --progress-bar off -e .[test] - run: pytest --tb=short --cov --cov-report=xml --cov-report=html -vvv refleak - uses: codecov/codecov-action@v7 if: success() || failure() with: token: ${{ secrets.CODECOV_TOKEN }} mne-tools-refleak-be8d511/.gitignore000066400000000000000000000034551524064325700174600ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ junit-results.xml .DS_Store /.claude/mne-tools-refleak-be8d511/.pre-commit-config.yaml000066400000000000000000000015671524064325700217530ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.20 hooks: - id: ruff-check args: ["--fix"] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.20 hooks: - id: ruff-format - repo: https://github.com/astral-sh/ty-pre-commit rev: v0.0.56 hooks: - id: ty additional_dependencies: - pytest==8.4.2 args: [--no-python-downloads, --no-project] - repo: https://github.com/adrienverge/yamllint rev: v1.38.0 hooks: - id: yamllint args: [--strict, -c, .yamllint.yml] - repo: https://github.com/pappasam/toml-sort rev: v0.24.4 hooks: - id: toml-sort-fix - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell additional_dependencies: - tomli types_or: [python, rst] mne-tools-refleak-be8d511/.yamllint.yml000066400000000000000000000001751524064325700201160ustar00rootroot00000000000000extends: default ignore: | .github/workflows/codeql-analysis.yml rules: line-length: disable document-start: disable mne-tools-refleak-be8d511/CLAUDE.md000066400000000000000000000154371524064325700167520ustar00rootroot00000000000000# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Overview `refleak` is a small, dependency-free library for finding what still holds a reference to an object that should be dead. The main entry points are `refleak.testing.assert_no_instances(cls, when=...)`, which asserts that no instances of `cls` survive `gc.collect()`, and `refleak.testing.Snapshot`, which records already-existing matching objects up front so only *new* survivors are flagged (`snap = Snapshot(match)` ... `snap.assert_no_new()`). On failure both render a box-drawing referrer chain explaining *why* each survivor is still alive. It was extracted from `mne.utils.misc._assert_no_instances` and targets test teardown for GUI/native-object leaks (Qt widgets, VTK actors, PyVista). ## Commands ```bash pip install -e .[test] # install with test deps (pytest, pytest-cov) pytest refleak # run the full suite (config in pyproject.toml) pytest refleak/testing/tests/test_core.py::test_assert_no_instances # single test pre-commit run --all-files # ruff (check+format), ty, yamllint, toml-sort, codespell ty check # type-check only (against Python 3.10, the minimum) ``` Note: `pytest` runs with `--doctest-modules`, so docstring examples in `_core.py` (and the referrer-chain output shown there) are executed as tests — keep them accurate when editing docstrings. ## Architecture Everything lives in `refleak/testing/_core.py`; the `__init__.py` files only re-export the four public names (`Snapshot`, `assert_no_instances`, `gc_collect_once`, `referrer_chain`). The referrer-tracing pipeline flows top-down: - `assert_no_instances` — scans `gc.get_objects()` for `isinstance(obj, cls)` survivors and asserts there are none. - `Snapshot` — records `id()`s of matching objects at construction (types use `isinstance`, anything else is a predicate callable); `assert_no_new()` re-scans and asserts nothing matching appeared since and survived. Stores only ids (pins nothing alive); the documented caveat is id reuse (false negatives only), which a `gc.collect()` does *not* really fix — CPython hands a just-freed address straight back to the next same-size object. - `Snapshot(..., freeze=True)` — the fast, strict alternative: `gc.freeze()` at construction (empty before-set, no collect, no scan) so everything the collector can still see at check time is new by construction. `objs=` is rejected, `collect` is irrelevant. `assert_no_new()` takes `gc.get_objects()` *while frozen* (that is what defines "new"), thaws in a `finally`, and only then matches/reports — `gc.get_referrers()` skips the permanent generation too, so reporting while frozen would hide exactly the pre-existing anchors (module-level registries) that `_build_report` needs to see, and a referrer-less survivor is dropped as "not a leak". A full `gc.get_objects()` is retaken after thawing only when there are survivors, keeping the passing path scan-free. Module-level `_freeze_depth` counts nesting because `gc.unfreeze()` empties the *whole* permanent generation (including CPython's own startup freeze and anyone else's); the public `thaw()` is idempotent, never raises, and only the outermost holder unfreezes. Freezing is process-wide, so all `gc` introspection (anyone's, not just ours) lies for the duration of the window. Testing note: on CPython 3.12 (alone) every full `gc.collect()` parks the interpreter's ~375 immortal objects in the permanent generation even with no freeze in effect, so "thawed" can only be asserted as `gc.get_freeze_count() <=` that per-process baseline (`_IMMORTAL_FREEZE_COUNT` in the tests), never `== 0`; "frozen" as `>` it. - Both share `_match_objects` (a raising match check counts as a miss) and `_build_report` (per-survivor `referrer_chain` + message lines; survivors with no non-excluded referrers don't count). - `referrer_chain` — public tracer: walks `gc.get_referrers` up to `max_depth` hops / `max_lines` nodes and returns rendered lines. - `_referrer_tree` — the recursion. Shared mutable state threaded through the call: `count` (1-element list, a global node cap), `excluded` (ids never shown/recursed, e.g. the traversal's own containers), `recursed` (ids already expanded, to break reference cycles). - `_describe_referrer` — formats one referrer as `name: type = repr` and decides `next_obj` (what to recurse into next, or `None` to stop). It collapses noise: an instance `__dict__` is reported as its owning instance, a closure cell is reported as `func.__closure__['varname']` and recursion continues from the owning function (skipping the anonymous closure tuple), and a module-level global or module attribute is named directly (`module.attr` / `module.__dict__['attr']`) and stops the walk — who holds the *module* is never the actionable part. `__dict__` owner attribution falls back to scanning the caller's `objs` snapshot because C-extension instances (VTK wrappers) don't visit their attribute dict in `tp_traverse`, hiding the owner from `gc.get_referrers`; a still-unowned dict previews its keys (often the only clue to the owning class of a VTK "ghost" `__dict__` whose wrapper already died), and any expanded node with zero visible referrers is marked `(no gc-visible referrers)` — the anchor is outside the Python heap (e.g. a C++-side reference). Two correctness constraints that pervade the code: - **The traversal must exclude its own state.** `gc.get_referrers` will report the lists/dicts/globals the tracer itself is holding as if they were real anchors. Callers pass `exclude_ids` (see `assert_no_instances` excluding `id(objs)`, `id(ref)`, `id(globals())`), and `_referrer_tree` adds `id(refs)` each level. Only *live-stack* frames are skipped (recomputed via `_live_frame_ids()` at each check so the traversal's own frames count); dead-but-referenced frames (stored tracebacks, suspended generators) are real anchors and are reported. When adding state that touches traced objects, exclude its id too. - **Reporting must never raise.** Survivors can be half-destroyed native objects, so all `repr()` goes through `_safe_repr`, and `isinstance` checks are wrapped (weakrefs etc. can raise). `gc_collect_once(request)` deduplicates `gc.collect()` per pytest test item (via a `_refleak_gc_collected` attr on the node) because collect cost scales with total tracked objects and multiple teardown fixtures would otherwise each pay it. ## Conventions - Python >= 3.10; no runtime dependencies (pytest is only referenced via an optional `request` argument, never imported). - Line length 88; ruff enforces pep257 docstrings. Every function has a docstring; private helpers are prefixed `_`. - Version is derived from git tags via `setuptools_scm` (no hardcoded version). mne-tools-refleak-be8d511/LICENSE000066400000000000000000000030101524064325700164600ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2026, Eric Larson and refleak contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. mne-tools-refleak-be8d511/README.rst000066400000000000000000000177261524064325700171650ustar00rootroot00000000000000refleak ======= Find out what's still holding a reference to an object that should be dead. ``refleak.testing.assert_no_instances(cls)`` checks that no instances of ``cls`` remain alive after garbage collection, and for any that do, reports a rendered referrer chain -- what's still holding on to it, and (recursively) what's holding on to *that* -- so tracking down a reference/GC leak (e.g. a lingering Qt widget, VTK actor, or GUI object in tests) doesn't require manually poking at ``gc.get_referrers`` by hand. Extracted from ``mne.utils.misc._assert_no_instances``, developed over several years of tracking down reference leaks in `MNE-Python `__, `PyVista `__, and `pyvistaqt `__. Installation can be performed via ``pip``:: pip install refleak Usage ----- .. code-block:: python import gc from refleak import testing class Leaky: pass _leaked = Leaky() # e.g. accidentally kept alive by a module-level cache del _leaked gc.collect() testing.assert_no_instances(Leaky, when="after test") A common pattern is a pytest fixture that runs the check on teardown: .. code-block:: python import pytest from refleak.testing import assert_no_instances @pytest.fixture def check_no_leaked_widgets(request): yield assert_no_instances(MyWidget, when="test teardown", request=request) When references are held, an AssertionError will be thrown. For example: .. code-block:: python import gc from refleak import testing class Leaky: pass class ClingyParent: some_dict: dict leaked = Leaky() parent = ClingyParent() parent.some_dict = {"leak_1": leaked} # e.g. accidentally kept alive by some object root_list = ["some_str", leaked, "some_other_str"] del leaked gc.collect() testing.assert_no_instances(Leaky, when="after test") Would result in: .. code-block:: console AssertionError: Found 1 __main__.Leaky @ after test: Leaky @ 0x102fe3e00: ├── dict['leak_1']: dict = │ └── __main__.ClingyParent.__dict__['some_dict']: dict = │ └── __main__.__dict__['parent']: dict = └── __main__.root_list[1]: list = Snapshots: only flag *new* instances ------------------------------------ ``assert_no_instances`` requires that *zero* instances exist, which is too strict when some legitimately pre-date the code under test -- for example VTK objects held by a plotting theme or a module-level cache. ``Snapshot`` records the matching objects that already exist so that only ones created afterwards (and still alive) are reported: .. code-block:: python import pytest from refleak.testing import Snapshot @pytest.fixture(autouse=True) def check_vtk_gc(request): """Ensure no VTK objects created during a test outlive it.""" def is_vtk(obj): return obj.__class__.__name__.startswith("vtk") snap = Snapshot(is_vtk, label="VTK") yield snap.assert_no_new(when="test teardown", request=request) ``match`` can be a type, a tuple of types, or a predicate callable, and failures render the same referrer chains as ``assert_no_instances``. Freeze mode: faster and stricter snapshots ------------------------------------------ By default a ``Snapshot`` records the ``id()`` of every matching object, which costs a ``gc.collect()`` and a full heap scan up front, plus another scan at check time. ``freeze=True`` instead calls ``gc.freeze()``, moving every live object into the permanent generation -- which ``gc.get_objects()`` never reports and the collector never walks -- so at check time everything still visible is *by construction* newer than the snapshot: .. code-block:: python @pytest.fixture(autouse=True) def check_vtk_gc(request): snap = Snapshot(is_vtk, label="VTK", freeze=True) try: yield snap.assert_no_new(when="test teardown", request=request) finally: snap.thaw() # no-op if the check above ran (it thaws itself) Nothing is recorded and nothing is scanned at snapshot time (~0.1 ms instead of ~60 ms on a 180k-object heap; one downstream suite went from 155 s to 83 s), and the check is also *stricter*: an ``id()`` is an address, and CPython readily hands a freed address straight back to the next object of the same size, so an id-based snapshot can mistake a genuine leak for the pre-existing object it replaced. Freezing has no ids to collide. The trade-off is that freezing is process-wide until ``thaw()`` (which ``assert_no_new`` calls for you, on every path including a failing one), and for that whole window ``gc.get_objects()`` and ``gc.get_referrers()`` lie to *everyone* -- e.g. Hypothesis's ``register_random`` checks reachability with ``gc.get_referrers()`` and warns spuriously inside a frozen window. Keep the frozen window as small as the code under test. Comparison to similar packages ------------------------------- There's no shortage of tools for poking at Python's garbage collector; here's how ``refleak`` fits in relative to the ones people reach for most. "Monthly downloads" is from `PyPI Stats `__ (July 2026) and includes CI/mirror traffic, so treat it as a rough popularity signal rather than a count of individual users. "Releases (5y)" counts releases in the last five years as a rough maintenance signal. .. list-table:: :header-rows: 1 * - Package - What it does - Monthly downloads - Latest release - Releases (5y) * - **refleak** - Assert no instances of a class remain alive; on failure, render the referrer chain keeping each survivor alive - new - -- - -- * - `objgraph `__ - General-purpose object-graph exploration: count objects by type, diff growth between snapshots, render backref/reference graphs via Graphviz - ~1.1M - 3.6.2 (Oct 2024) - 3 * - `Pympler `__ - Broader memory-profiling suite: object sizing (``asizeof``), live monitoring (``muppy``), and class-level lifetime tracking (``ClassTracker``) - ~5.5M - 1.1 (Jun 2024) - 3 * - `guppy3 `__ (heapy) - Python 3 port of the classic ``guppy``/``heapy`` heap analysis toolset, with a query language for slicing the whole heap by type, size, or referrer - ~1.2M - 3.1.7 (May 2026) - 7 * - `pytest-leaks `__ - pytest plugin that reruns each test several times and watches ``sys.gettotalrefcount()`` for growth, rather than checking specific classes - ~2.4k - 0.3.1 (Nov 2019) - 0 (unmaintained) None of the alternatives above do exactly what ``refleak`` does: assert that no instances of a *specific class* remain alive and, on failure, explain *why* via a rendered referrer chain, in a form meant to be dropped straight into a test suite's teardown. ``objgraph`` and ``guppy3``/``heapy`` can answer the same "why is this still alive" question (and go well beyond it, e.g. full heap graphs and queries), but require driving their APIs interactively or wiring up Graphviz output yourself rather than getting an assertion with a readable message for free. ``Pympler`` is aimed more at memory *sizing* and monitoring over time than one-shot leak assertions. ``pytest-leaks`` checks for leaks generically (via total refcount growth across repeated runs) instead of targeting specific classes, so it can flag *that* something leaked without telling you *what* or *why*. If you need full heap introspection or memory-size profiling, reach for ``objgraph`` or ``Pympler``/``guppy3`` instead; if you just want a pytest-friendly assertion that a GUI widget, VTK actor, or other object didn't leak, and a readable explanation when it did, that's what ``refleak`` is for. mne-tools-refleak-be8d511/codecov.yml000066400000000000000000000010211524064325700176200ustar00rootroot00000000000000comment: false github_checks: # too noisy, even though "a" interactively disables them annotations: false codecov: notify: require_ci_to_pass: false coverage: status: patch: default: informational: true target: 95% if_no_uploads: error if_not_found: success if_ci_failed: failure project: default: false library: informational: true target: 90% if_no_uploads: error if_not_found: success if_ci_failed: failure mne-tools-refleak-be8d511/pyproject.toml000066400000000000000000000064201524064325700203770ustar00rootroot00000000000000[build-system] requires = ["setuptools>=64", "setuptools_scm>=6.2"] build-backend = "setuptools.build_meta" [project] name = "refleak" description = "Find out what's still holding a reference to an object that should be dead." readme = "README.rst" license = {text = "BSD-3-Clause"} maintainers = [ {name = "Eric Larson", email = "larson.eric.d@gmail.com"} ] requires-python = ">=3.10" dynamic = ["version"] dependencies = [] classifiers = [ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Topic :: Software Development", "Topic :: Software Development :: Debuggers", "Topic :: Software Development :: Testing", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Operating System :: Unix", "Operating System :: MacOS", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14" ] [project.optional-dependencies] test = [ "pytest", "pytest-cov" ] [project.urls] Homepage = "https://github.com/mne-tools/refleak" Download = "https://github.com/mne-tools/refleak" [tool.check-manifest] # .git_archival.txt: setuptools_scm's automatic sdist file-finder relies on # a real `.git` directory, which check-manifest's clean-room re-build (a # plain file copy, no `.git`) doesn't have, so this one file is invisible to # that second pass even though it's present in normal builds (verified via # `python -m build`). ignore = [ ".coveragerc", ".git_archival.txt", ".pre-commit-config.yaml", ".yamllint.yml", "CLAUDE.md", "codecov.yml" ] [tool.codespell] builtin = "clear,rare,informal,names,usage" [tool.pytest.ini_options] addopts = """\ --durations=20 --doctest-modules -ra --cov-report= --tb=short \ --doctest-ignore-import-errors --junit-xml=junit-results.xml \ --capture=sys\ """ junit_family = "xunit2" [tool.ruff] line-length = 88 [tool.ruff.lint] select = ["A", "B006", "D", "E", "F", "I", "UP", "UP031", "W"] ignore = [ "D100", # Missing docstring in public module "D104", # Missing docstring in public package "D107", # Missing docstring in __init__ "D301", # Use `r"""` if any backslashes in a docstring "D413" # Missing blank line after last section ] extend-select = [ "D214", # Section is over-indented "D215", # Section underline is over-indented "D404", # First word of the docstring should not be `This` "D405", # Section name should be properly capitalized "D406", # Section name should end with a newline "D407", # Missing dashed underline after section "D408", # Section underline should be in the line following the section's name "D409", # Section underline should match the length of its name "D410", # Missing blank line after section "D411" # Missing blank line before section ] [tool.ruff.lint.pydocstyle] convention = "pep257" [tool.setuptools] zip-safe = false include-package-data = false [tool.setuptools.package-data] refleak = ["py.typed"] [tool.setuptools.packages.find] include = ["refleak*"] [tool.setuptools_scm] [tool.ty.environment] # check against the minimum supported interpreter (see requires-python) python-version = "3.10" mne-tools-refleak-be8d511/refleak/000077500000000000000000000000001524064325700170725ustar00rootroot00000000000000mne-tools-refleak-be8d511/refleak/__init__.py000066400000000000000000000005611524064325700212050ustar00rootroot00000000000000"""Find out what's still holding a reference to an object that should be dead.""" # Authors: Eric Larson # # License: BSD-3-Clause from importlib.metadata import version from . import testing try: __version__ = version("refleak") except Exception: __version__ = "0.0.0" del version __all__ = [ "__version__", "testing", ] mne-tools-refleak-be8d511/refleak/py.typed000066400000000000000000000000001524064325700205570ustar00rootroot00000000000000mne-tools-refleak-be8d511/refleak/testing/000077500000000000000000000000001524064325700205475ustar00rootroot00000000000000mne-tools-refleak-be8d511/refleak/testing/__init__.py000066400000000000000000000005211524064325700226560ustar00rootroot00000000000000"""Testing helpers for finding reference/GC leaks.""" # Authors: Eric Larson # # License: BSD-3-Clause from ._core import ( Snapshot, assert_no_instances, gc_collect_once, referrer_chain, ) __all__ = [ "Snapshot", "assert_no_instances", "gc_collect_once", "referrer_chain", ] mne-tools-refleak-be8d511/refleak/testing/_core.py000066400000000000000000001074741524064325700222250ustar00rootroot00000000000000"""Core reference-leak detection and reporting.""" # Authors: Eric Larson # # License: BSD-3-Clause import gc import inspect import sys import types # How many freeze-mode Snapshots currently hold the heap frozen. gc.unfreeze() # empties the *entire* permanent generation -- ours and anything any other # actor froze alike -- so it can only be called once the outermost holder is # done (see Snapshot.thaw). _freeze_depth = 0 def _fullname(obj): if inspect.ismodule(obj): # Otherwise every module shows up identically as just "module", # which is exactly the info we need to tell which one is which. return getattr(obj, "__name__", "") klass = obj if inspect.isclass(obj) else obj.__class__ module = klass.__module__ name = klass.__qualname__ if module != "builtins": name = f"{module}.{name}" return name def _key_suffix(obj, referent): """Return the ``[...]``-like Python-syntax suffix to reach referent from obj.""" if isinstance(obj, (list, tuple)): for ii, item in enumerate(obj): if item is referent: return f"[{ii}]" elif isinstance(obj, dict): for key, value in obj.items(): if key is referent: return "-key" if value is referent: return f"[{key!r}]" return "" def gc_collect_once(request=None): """Call ``gc.collect()``, deduplicated once per test item if given a request. ``gc.collect()`` cost scales with the number of tracked objects in the whole process, so when several independent test fixtures each want a fresh collect during the same test's teardown, doing it more than once is a significant and unnecessary fraction of total test time. When ``request`` (a pytest fixture request) is given, only the first call for a given test item actually collects; later calls are no-ops. Parameters ---------- request : pytest.FixtureRequest | None If given, deduplicate the collection per test item. """ if request is None: gc.collect() return node = request.node if getattr(node, "_refleak_gc_collected", False): return node._refleak_gc_collected = True gc.collect() def _safe_repr(obj, *, maxlen=100): """Get a repr that cannot raise (e.g., on a deleted VTK/Qt C++ object).""" try: rep = repr(obj) except Exception as exc: return f"" return rep[:maxlen].replace("\n", " ") def _dict_owner(d, objs=None): """Find the object whose __dict__ (or similar) *is* d, if any. C-extension instances (e.g. VTK wrappers) may not visit their attribute dict in ``tp_traverse``, leaving the owner invisible to ``gc.get_referrers``; when ``objs`` (a ``gc.get_objects()`` snapshot) is available, fall back to scanning it for the owner directly. Both scans tolerate objects whose ``__dict__`` access raises. """ for o in gc.get_referrers(d): try: if getattr(o, "__dict__", None) is d: return o except Exception: continue for o in objs if objs is not None else (): try: if getattr(o, "__dict__", None) is d: return o except Exception: continue return None def _dict_keys_preview(d, maxkeys=4): """Summarize a dict's keys, to hint at whose attribute dict it might be. For an "orphaned" instance ``__dict__`` -- e.g. one kept alive by VTK's ghost mechanism after its owner's Python wrapper died -- the attribute names are often the only clue left to identify the owning class. """ keys = list() for i, k in enumerate(d): if i >= maxkeys: keys.append("...") break keys.append(_safe_repr(k, maxlen=40)) return "[" + ", ".join(keys) + "]" def _cell_owner(cell): """Find the (function, freevar name) whose closure holds ``cell``, if any. Naming the closing-over function and its variable is what makes a leak via an enclosing scope (a classic reference-cycle source) actionable; the cell object itself is anonymous. """ for t in gc.get_referrers(cell): if not isinstance(t, tuple): continue for f in gc.get_referrers(t): if inspect.isfunction(f) and f.__closure__ is t: # A tuple only refers to its elements, so cell must be in t # (compare by identity: cells compare by their contents). idx = [id(c) for c in t].index(id(cell)) return f, f.__code__.co_freevars[idx] return None, None def _attr_name(obj, referent): """Find the attribute of obj whose value *is* referent, if any. On Python >= 3.11 an instance holding referent as an attribute typically shows up as a referrer directly (inline values, or ``__slots__`` on any version) rather than via its ``__dict__``, so without this the failure message would name the holder but not *which* attribute does the holding. """ if referent is None: return None try: items = list(vars(obj).items()) except TypeError: # no __dict__ (e.g. __slots__-only) items = list() for klass in type(obj).__mro__: slots = getattr(klass, "__slots__", ()) for slot in (slots,) if isinstance(slots, str) else slots: try: items.append((slot, getattr(obj, slot))) except AttributeError: # unset slot continue for key, val in items: if val is referent: return key return None def _module_globals_map(): """Snapshot ``{id(obj): "module.attr"}`` for all module-level globals. This is what lets a failure message name e.g. a long-lived module-level registry (cache, weak-value dict, etc.) directly, which is often the actual reason an object outlives a single test/example: a plain ``gc.get_referrers`` walk only shows an anonymous ``dict``/``list``. Snapshotting once per chain (rather than rescanning all of ``sys.modules`` for every tree node) keeps rendering fast in processes with many/large modules; storing only ids and names means the map itself keeps nothing alive. """ out = dict() for modname, mod in list(sys.modules.items()): d = getattr(mod, "__dict__", None) if not d: continue try: items = list(d.items()) except Exception: continue for key, val in items: out.setdefault(id(val), f"{modname}.{key}") return out def _live_frame_ids(): """Get ``id()``\\ s of frames currently executing in any thread. Objects referenced by these frames are the traversal's own machinery and the caller's live stack -- never leaks. Frames *not* here but still alive (a stored traceback, a suspended generator/coroutine) are real anchors, classically an exception saved somewhere. Computed fresh at each use so the traversal's own frames are always included. """ ids = set() for frame in sys._current_frames().values(): while frame is not None: ids.add(id(frame)) frame = frame.f_back return ids def _describe_referrer(r, referent, global_names=None, objs=None): """Build a "name: type = repr"-style description of r, which refers to referent. Mirroring a Python variable declaration keeps every referrer kind parseable the same way: a name (the best Python-syntax expression for reaching ``r``, falling back to its type when nothing better is known), its type (omitted when it would just repeat the name), and a repr -- for containers (dict/list/tuple) this is always at least a length summary rather than their (possibly huge) contents. ``global_names`` is a :func:`_module_globals_map` snapshot (built here if not given), and ``objs`` an optional ``gc.get_objects()`` snapshot for owner attribution (see :func:`_dict_owner`). Returns ------- desc : str Human-readable, safe description of r. next_obj : object | None What to keep tracing referrers of (``None`` to stop here). This is usually ``r`` itself, but for e.g. an instance's ``__dict__`` it's the owning instance (tracing the dict's own referrers is normally just uninformative interpreter-internal noise), for a closure cell it's the function closing over it (the ``__closure__`` tuple in between is likewise noise), and for a module-level global it's ``None`` (a named global is already a fully-explained anchor; nothing more useful to say). """ if global_names is None: global_names = _module_globals_map() if inspect.isframe(r): # Only non-executing frames get here (see _referrer_tree): a frame # kept alive by a stored traceback or a suspended generator really # does anchor its locals. code = r.f_code qual = getattr(code, "co_qualname", code.co_name) # co_qualname: 3.11+ return f"frame of {qual}: frame = {code.co_filename}:{r.f_lineno}", r if inspect.ismethod(r): name = r.__func__.__qualname__ return f"{name}: method = {_safe_repr(r.__self__)}", r if inspect.isfunction(r): return f"{r.__qualname__}: function = {_safe_repr(r)}", r if inspect.ismodule(r): return f"{_fullname(r)}: module = {_safe_repr(r)}", r if isinstance(r, dict): suffix = _key_suffix(r, referent) owner = _dict_owner(r, objs) if owner is not None: # e.g. "some.module.SomeClass.__dict__['attr']: dict = " name = f"{_fullname(owner)}.__dict__{suffix}" # A module attribute is a fully-explained anchor: what refers to # the *module* (importers, sys.modules, parent packages) is never # the actionable part, so stop there like for named globals. next_obj = None if inspect.ismodule(owner) else owner return f"{name}: dict = ", next_obj global_name = global_names.get(id(r)) if global_name is not None: # e.g. "sys.modules['__main__']: dict = " return f"{global_name}{suffix}: dict = ", None # Anonymous dict: preview the sibling keys (unless the suffix already # showed the only one) to hint at whose attribute dict this might be. preview = f", keys={_dict_keys_preview(r)}" if len(r) > 1 else "" return f"dict{suffix}: dict = ", r if isinstance(r, (list, tuple)): suffix = _key_suffix(r, referent) kind = "list" if isinstance(r, list) else "tuple" global_name = global_names.get(id(r)) if global_name is not None: return f"{global_name}{suffix}: {kind} = ", None return f"{kind}{suffix}: {kind} = ", r if isinstance(r, types.CellType): # a closure variable # A cell's contents *is* the referent (its only reference), so # repeating it here would be redundant; like the other containers, # summarize the closure by length instead. owner, varname = _cell_owner(r) if owner is not None: # e.g. "cb.__closure__['widget']: cell = " name = f"{owner.__qualname__}.__closure__[{varname!r}]" return f"{name}: cell = ", owner return f"cell = {_safe_repr(r)}", r # e.g. ".the_widget" when r holds referent as an instance attribute attr = _attr_name(r, referent) suffix = f".{attr}" if attr is not None else "" global_name = global_names.get(id(r)) if global_name is not None: return f"{global_name}{suffix}: {_fullname(r)} = {_safe_repr(r)}", None if suffix: name = _fullname(r) return f"{name}{suffix}: {name} = {_safe_repr(r)}", r # Here the best available "name" is just the type, so writing both (e.g. # "generator: generator = ...") would only stutter. return f"{_fullname(r)} = {_safe_repr(r)}", r def _referrer_tree( o, depth, *, max_depth, max_lines, count, excluded, recursed, root_id, global_names, objs, ): """Recursively build a tree of (description, children) referrer nodes. ``excluded`` objects (e.g. the huge ``gc.get_objects()`` snapshot) are never shown or recursed into. ``recursed`` tracks objects already used as a recursion root, so a cycle in the referrer graph can't recurse forever; unlike ``excluded`` it doesn't prevent an object from being *listed* (only from being expanded again). ``count`` is a 1-element list used as a mutable counter shared across the whole recursion, so the total number of nodes (not just per-level) is capped at ``max_lines``. So that a childless node isn't ambiguous, a node whose expansion target was skipped gets a marker: ``(cycle back to 0x...)`` when the chain has come back around to the traced object itself (``root_id``) -- i.e. a reference cycle -- and ``(see above)`` when it was already expanded earlier in the tree. A node that *was* expanded but has no gc-visible referrers at all gets ``(no gc-visible referrers)``: its anchor lives outside the Python heap (e.g. a C++-side reference such as VTK's ghost mechanism), which is exactly the hint needed to stop looking for a Python culprit. """ nodes = list() refs = gc.get_referrers(o) # While this list is alive (i.e. for the duration of this call, including # any recursive calls below), it itself shows up as a "referrer" of any # of its own elements if we ask gc.get_referrers() about them -- that's # an artifact of doing this traversal at all, not a real anchor. excluded.add(id(refs)) for r in refs: if count[0] >= max_lines: nodes.append(("... (truncated)", [])) return nodes # Live-stack frames (recomputed here so this traversal's own frames # are included) are machinery, not leaks; dead-but-referenced frames # (stored tracebacks, suspended generators) are real anchors and are # kept. if id(r) in excluded or (inspect.isframe(r) and id(r) in _live_frame_ids()): continue count[0] += 1 desc, next_obj = _describe_referrer(r, o, global_names, objs) children = list() if next_obj is not None and id(next_obj) not in excluded: if id(next_obj) == root_id: desc += f" (cycle back to 0x{root_id:x})" elif id(next_obj) in recursed: desc += " (see above)" elif depth + 1 < max_depth: recursed.add(id(next_obj)) children = _referrer_tree( next_obj, depth + 1, max_depth=max_depth, max_lines=max_lines, count=count, excluded=excluded, recursed=recursed, root_id=root_id, global_names=global_names, objs=objs, ) if not children: desc += " (no gc-visible referrers)" nodes.append((desc, children)) del r del refs return nodes def _render_tree(nodes, prefix=""): """Render a (description, children) tree using box-drawing characters.""" lines = list() for i, (desc, children) in enumerate(nodes): last = i == len(nodes) - 1 lines.append(prefix + ("└── " if last else "├── ") + desc) child_prefix = prefix + (" " if last else "│ ") lines.extend(_render_tree(children, child_prefix)) return lines def referrer_chain(obj, *, max_depth=5, max_lines=40, exclude_ids=(), objs=None): """Describe, recursively, what holds references to obj. Referrers are walked up to ``max_depth`` hops and rendered as a tree, so that a leaked object's actual anchor (e.g. a module-level registry several containers away) is visible directly in the failure message, instead of just the immediate (often uninformative, e.g. a bare ``list``) referrer. Parameters ---------- obj : object The object to trace referrers of. max_depth : int Maximum number of referrer hops to walk. max_lines : int Maximum number of lines (nodes) to render in total. exclude_ids : Iterable[int] ``id()``\\ s of objects to treat as if they don't exist (e.g. any containers the caller itself is using to hold state during the traversal). objs : list | None The result of ``gc.get_objects()``, if already computed by the caller. Used to attribute an instance ``__dict__`` to its owner when the owner's ``tp_traverse`` doesn't visit the dict (e.g. VTK wrappers), which hides it from ``gc.get_referrers``. Returns ------- lines : list[str] Rendered tree lines, one referrer per line. A line ending in ``(cycle back to 0x...)`` reached ``obj`` itself again (a reference cycle); one ending in ``(see above)`` reached something already expanded earlier in the tree; one ending in ``(no gc-visible referrers)`` was expanded but nothing in the Python heap refers to it -- its anchor is outside Python (e.g. a C++-side reference). has_referrers : bool Whether any (non-excluded, non-live-frame) referrers were found at all. """ excluded = set(exclude_ids) recursed = {id(obj)} nodes = _referrer_tree( obj, 0, max_depth=max_depth, max_lines=max_lines, count=[0], excluded=excluded, recursed=recursed, root_id=id(obj), global_names=_module_globals_map(), objs=objs, ) return _render_tree(nodes), len(nodes) > 0 def _found_summary(n, what, when): """Build the "Found N [@ when]:" summary line.""" out = f"Found {n} {what}" if when: out += f" @ {when}" return out + ":" def _match_objects(match, objs): """Yield the objects in objs that match, treating a raising check as a miss. ``match`` is either a type / tuple of types (checked with ``isinstance``) or a predicate callable. The check runs on arbitrary heap objects, so it can raise for exotic ones (weakref proxies, half-destroyed native wrappers); those are misses, not errors. """ if isinstance(match, (type, tuple)): for obj in objs: try: if isinstance(obj, match): yield obj except Exception: # such as a weakref pass else: for obj in objs: try: if match(obj): yield obj except Exception: # e.g. a predicate poking a dead C++ proxy pass def _build_report(survivors, *, objs, extra_info, max_depth, max_lines): """Build (count, message lines) for survivors, each with a referrer chain. Only survivors with at least one non-excluded referrer count: one held alive solely by the traversal's own containers isn't a leak. """ n = 0 ref = list() for obj in survivors: extra = list(extra_info(obj)) if extra_info is not None else list() lines, has_referrers = referrer_chain( obj, max_depth=max_depth, max_lines=max_lines, exclude_ids={id(objs), id(survivors), id(ref), id(globals())}, objs=objs, ) if has_referrers: ref.extend(extra) # id() tags just the survivors themselves (not every tree # node): it distinguishes multiple instances of a class from # each other and can be correlated with "<... object at 0x...>" # reprs elsewhere. The summary line already gives the full # class name, so the short name suffices here (while still # revealing subclasses). ref.append(f"{obj.__class__.__qualname__} @ 0x{id(obj):x}:") ref.extend(lines) n += 1 del obj return n, ref def assert_no_instances( cls, when="", *, request=None, objs=None, extra_info=None, max_depth=5, max_lines=40, ): """Assert that no instances of ``cls`` are still alive. For any surviving instance, the failure message includes a rendered referrer chain (see :func:`referrer_chain`) explaining what's still holding a reference to it, which is usually far more actionable than a bare instance count. Each survivor's section is headed by its type and ``id()`` (in hex, to match default object reprs). Parameters ---------- cls : type The class to check for live instances of. when : str A short description of when this check is happening, included in the assertion message (e.g. ``"after test"``). request : pytest.FixtureRequest | None If given, deduplicate the ``gc.collect()`` call across checks within the same test item (see :func:`gc_collect_once`). objs : list | None The result of ``gc.get_objects()`` to check, if already computed by the caller. If ``None``, it is computed here. extra_info : Callable[[object], list[str]] | None If given, called with each surviving instance to produce extra lines (e.g. instance-specific diagnostic state) prepended to its entry in the failure message. max_depth : int Maximum number of referrer hops to walk per surviving instance (see :func:`referrer_chain`). max_lines : int Maximum number of tree lines to render per surviving instance (see :func:`referrer_chain`). """ __tracebackhide__ = True gc_collect_once(request) if objs is None: objs = gc.get_objects() survivors = list(_match_objects(cls, objs)) # e.g., vtkPolyData, Brain, ... n, ref = _build_report( survivors, objs=objs, extra_info=extra_info, max_depth=max_depth, max_lines=max_lines, ) del objs, survivors assert n == 0, ( "\n" + _found_summary(n, _fullname(cls), when) + "\n" + "\n".join(ref) ) class Snapshot: """Snapshot of live matching objects, to later assert none *new* survive. :func:`assert_no_instances` requires that *zero* instances exist, which is too strict when some legitimately pre-date the code under test (e.g. VTK objects held by a theme or module-level cache). A ``Snapshot`` records the ``id()``\\ s of matching objects up front so :meth:`assert_no_new` can flag only what appeared afterwards and survived garbage collection -- the pattern used by ``check_gc``-style pytest fixtures: snapshot before the test body, assert after. Only ids are stored, so a ``Snapshot`` itself keeps nothing alive. The unavoidable caveat of id-based snapshotting is id reuse: an ``id()`` is an address, and CPython's allocator hands back the *most recently freed* block of a size class, so an object created right after a pre-existing one of the same size died is very likely (measured: 199 times out of 200 in a tight loop) to land at exactly that address -- where it is indistinguishable from the object it replaced and is silently not reported. That is a false negative, never a false positive, and ``collect=True`` does not really fix it: collecting *frees* pre-existing objects, which is what creates the reusable addresses in the first place. ``freeze=True`` has no ids to collide and no such window (see `Notes`). Parameters ---------- match : type | tuple[type, ...] | callable What counts as a matching object: types are checked with ``isinstance``, anything else is called with each candidate object and should return truthy for a match. A check that raises (e.g. on a weakref proxy or a half-destroyed native wrapper) counts as a miss. label : str | None Adjective for matching objects in the failure summary, e.g. ``"VTK"`` renders as ``"Found 2 new VTK objects"``. By default a type (or tuple of types) is named directly and a callable adds nothing (``"Found 2 new objects"``). collect : bool Call ``gc.collect()`` before recording ids (default ``True``). Skip only when a collect is prohibitively slow at snapshot time and the increased id-reuse window is acceptable. Ignored with ``freeze=True`` (nothing is recorded, so there is nothing for a collect to affect). objs : list | None The result of ``gc.get_objects()`` to snapshot, if already computed by the caller. If given, ``collect`` is ignored -- any desired collect must have happened before ``objs`` was computed. If ``None``, it is computed here. Not allowed with ``freeze=True``. freeze : bool Use ``gc.freeze()`` instead of recording ids (default ``False``); faster and stricter, at the cost of a process-wide frozen heap until :meth:`thaw` (see `Notes`). Notes ----- ``freeze=True`` replaces the id bookkeeping with ``gc.freeze()``, which moves every currently-live object into the permanent generation. Neither ``gc.get_objects()`` nor ``gc.get_referrers()`` reports permanent-generation objects and the collector never walks them, so at check time everything the collector can still see is *by construction* newer than the snapshot: the before-set is empty, and no ``gc.collect()`` and no heap scan are needed at snapshot time (hence ``collect`` and ``objs`` do not apply; passing ``objs`` with ``freeze=True`` is a ``TypeError``). On a ~180k-object heap that is ~0.1 ms instead of ~60 ms per check, which took one downstream test suite from 155 s to 83 s. :meth:`assert_no_new` takes its ``gc.get_objects()`` snapshot while still frozen (that snapshot is what "new" *means*), then thaws *before* matching and reporting: while frozen, a survivor whose only anchor is a container that pre-dates the freeze (any module-level registry or cache) looks like it has no referrers at all, and a survivor with no referrers is deliberately not reported as a leak. On the failure path only, the heap is re-scanned after thawing so referrer chains see all of it; the passing path stays scan-free. .. warning:: Freezing is process-wide and lasts until :meth:`thaw` (which :meth:`assert_no_new` calls for you). For that whole window *every* consumer of ``gc`` introspection sees a heap that lies -- e.g. Hypothesis's ``register_random`` checks reachability with ``gc.get_referrers()`` and emits a spurious "looks collectable" warning if it runs inside a frozen window. Keep the window as small as the code under test, and call :meth:`thaw` on any path that bails out before the check. Freeze mode nests: an inner freeze-mode ``Snapshot`` (e.g. one taken by a nested in-process pytest session) freezes again, and only the outermost :meth:`thaw` actually calls ``gc.unfreeze()`` -- which empties the whole permanent generation, everyone else's freezes included. The inner freeze absorbs objects created since the outer one, so the outer check can no longer report those: nesting trades some of the outer check's coverage for a correct inner one. Examples -------- >>> from refleak.testing import Snapshot >>> class Widget: ... pass >>> pre_existing = Widget() # recorded in the snapshot, never reported >>> snap = Snapshot(Widget) >>> transient = Widget() >>> del transient >>> snap.assert_no_new(when="after test") # passes Freeze mode looks the same, but the heap is frozen in between (so the check must always be reached, or :meth:`thaw` called instead): >>> snap = Snapshot(Widget, freeze=True) >>> transient = Widget() >>> del transient >>> snap.assert_no_new(when="after test") # thaws, then passes """ def __init__(self, match, *, label=None, collect=True, objs=None, freeze=False): if isinstance(match, tuple): if not all(isinstance(m, type) for m in match): msg = f"match tuple must contain only types, got {match!r}" raise TypeError(msg) elif not isinstance(match, type) and not callable(match): msg = f"match must be a type, tuple of types, or callable, got {match!r}" raise TypeError(msg) self._match = match if label is None: if isinstance(match, type): label = _fullname(match) elif isinstance(match, tuple): label = "(" + " | ".join(_fullname(m) for m in match) + ")" else: label = "" self._label = label self._frozen = False if not freeze: if objs is None: if collect: # A plain collect, not gc_collect_once(request): the # once-per-item deduplication would make this setup-time # collect suppress the teardown-time one in assert_no_new # -- the collect that matters. gc.collect() objs = gc.get_objects() self._before_ids = {id(obj) for obj in _match_objects(match, objs)} return if objs is not None: msg = ( "objs= cannot be combined with freeze=True: the frozen heap " "itself is what defines the before-set" ) raise TypeError(msg) # Everything the collector can still see after this is newer than the # snapshot, so there is nothing to record (and no reason to collect or # scan -- that is the whole speed win). self._before_ids = set() # Freeze last and account for it immediately: anything raising between # gc.freeze() and the bookkeeping below would leave the process heap # frozen with nobody able to thaw it. global _freeze_depth gc.freeze() _freeze_depth += 1 self._frozen = True def thaw(self): """Undo this snapshot's ``gc.freeze()``, if it made one. Idempotent, safe on a non-freeze snapshot, and never raises -- it runs from :meth:`assert_no_new` while an assertion may be in flight, and from user-written abort paths (a fixture bailing out before the check). ``gc.unfreeze()`` empties the whole permanent generation, so it happens only once the outermost freeze-mode snapshot is done; an inner one merely gives up its claim. To check the result, note that ``gc.get_freeze_count()`` is a live census of the permanent generation rather than a count of freezes: it drops as frozen objects are refcount-deallocated, so being frozen can only be tested as "above the pre-freeze baseline". Unfreezing empties that generation outright, so the count is 0 immediately afterwards -- but on CPython 3.12 (alone) every full collection parks the interpreter's few hundred immortal objects (static types and the like) back into it, with no freeze in effect, so once a collect has run a thawed heap reads as that small stable per-process baseline rather than 0. """ if not self._frozen: # never froze, or already thawed return self._frozen = False global _freeze_depth _freeze_depth = max(_freeze_depth - 1, 0) if _freeze_depth == 0: try: gc.unfreeze() except Exception: # pragma: no cover pass def assert_no_new( self, when="", *, request=None, objs=None, extra_info=None, max_depth=5, max_lines=40, ): """Assert that no matching objects newer than the snapshot are alive. For any surviving new object, the failure message includes a rendered referrer chain (see :func:`referrer_chain`) explaining what's still holding a reference to it. Each survivor's section is headed by its type and ``id()`` (in hex, to match default object reprs). A freeze-mode snapshot is thawed here, on every path (including a failing assertion), so it is effectively single-use: with the heap thawed its empty before-set no longer means "nothing pre-existed", and a second call therefore degenerates into an :func:`assert_no_instances`-style "no matching objects at all" check. That errs toward over-reporting, never toward missing a leak; take a fresh ``Snapshot`` per check instead. Parameters ---------- when : str A short description of when this check is happening, included in the assertion message (e.g. ``"after test"``). request : pytest.FixtureRequest | None If given, deduplicate the ``gc.collect()`` call across checks within the same test item (see :func:`gc_collect_once`). objs : list | None The result of ``gc.get_objects()`` to check, if already computed by the caller. If ``None``, it is computed here (in freeze mode, while the heap is still frozen -- which is what makes everything in it new). Passing it in freeze mode is allowed but bypasses that: the snapshot is still thawed, and the given objects are checked as-is. extra_info : Callable[[object], list[str]] | None If given, called with each surviving object to produce extra lines (e.g. instance-specific diagnostic state) prepended to its entry in the failure message. max_depth : int Maximum number of referrer hops to walk per surviving object (see :func:`referrer_chain`). max_lines : int Maximum number of tree lines to render per surviving object (see :func:`referrer_chain`). """ __tracebackhide__ = True # In freeze mode this snapshot is what "new" means: the objects the # collector can still see while frozen are exactly the ones created # since. Everything after it runs thawed -- a finally so no failure # path can leave the process heap frozen forever. frozen_objs = objs is None and self._frozen try: gc_collect_once(request) if objs is None: objs = gc.get_objects() finally: # Thaw before matching and reporting: gc.get_referrers() skips the # permanent generation just like gc.get_objects() does, so while # frozen a survivor anchored only by a pre-existing container (a # module-level registry, say) looks referrer-less -- and a # referrer-less survivor is deliberately not reported as a leak. self.thaw() before = self._before_ids survivors = [ obj for obj in _match_objects(self._match, objs) if id(obj) not in before ] if survivors and frozen_objs: # Only the failure path pays for a full heap scan (keeping the # passing path scan-free is the point of freeze mode): the frozen # snapshot above holds only post-freeze objects, which is too # narrow a view for _dict_owner's fallback scan. Drop the frozen # snapshot *before* rescanning: alive, it would land in the new # scan (which only excludes its own id) and show up in every # survivor's report as a bogus anonymous-list anchor. del objs objs = gc.get_objects() n, ref = _build_report( survivors, objs=objs, extra_info=extra_info, max_depth=max_depth, max_lines=max_lines, ) del objs, survivors what = f"new {self._label} object" if self._label else "new object" what += "" if n == 1 else "s" assert n == 0, "\n" + _found_summary(n, what, when) + "\n" + "\n".join(ref) mne-tools-refleak-be8d511/refleak/testing/tests/000077500000000000000000000000001524064325700217115ustar00rootroot00000000000000mne-tools-refleak-be8d511/refleak/testing/tests/__init__.py000066400000000000000000000000001524064325700240100ustar00rootroot00000000000000mne-tools-refleak-be8d511/refleak/testing/tests/test_core.py000066400000000000000000000642211524064325700242570ustar00rootroot00000000000000"""Tests for refleak.testing.""" # Authors: Eric Larson # # License: BSD-3-Clause import gc import importlib import importlib.metadata import sys import types import pytest import refleak from refleak import testing from refleak.testing import Snapshot, _core, assert_no_instances from refleak.testing._core import ( _describe_referrer, _fullname, _key_suffix, _module_globals_map, _safe_repr, ) class _Leaky: """A trivial class to instantiate and (optionally) leak.""" def __init__(self): self.ref: _Leaky | None = None # for testing cycles _leaked: _Leaky | None = None _holder_list: list | None = None _holder_dict: dict | None = None _hidden_dict: dict | None = None _saved_exc: Exception | None = None @pytest.fixture(autouse=True) def _reset_globals(): """Clear every module-level leak-holder global on teardown.""" yield global _leaked, _holder_list, _holder_dict, _hidden_dict, _saved_exc _leaked = _holder_list = _holder_dict = _hidden_dict = _saved_exc = None # CPython 3.12 (alone: 3.10, 3.11, 3.13, and 3.14 all read 0) parks the # interpreter's own immortal objects (static types and the like, ~375 of them) # in the permanent generation on every full collection, even with no # gc.freeze() in effect -- the process even starts that way. So once any # collect has run on an unfrozen heap, "the heap is thawed" cannot be asserted # as a freeze count of exactly zero, only as "no more than this baseline", # measured here at import time on an unfrozen heap. Stable within a process: # immortal objects never die, and the set is fixed at startup. gc.collect() _IMMORTAL_FREEZE_COUNT = gc.get_freeze_count() @pytest.fixture(autouse=True) def _unfrozen_heap(): """Guarantee no test can leave the process heap frozen behind it. The freeze-mode tests drive the machinery by hand, so an assertion failing partway through one of them can strand a freeze: the nesting counter never gets decremented, ``gc.unfreeze()`` is never reached, and every later test silently runs against a frozen (i.e. lying) heap -- which degrades unrelated checks instead of failing loudly. Autouse and unconditional (rather than a try/finally per test) so that this holds for tests that don't even mean to freeze, and for any added later. """ yield _core._freeze_depth = 0 gc.unfreeze() class _GhostlyOwner: """Mimic a C-extension instance whose tp_traverse skips its __dict__. VTK wrappers really behave this way: ``obj.__dict__`` works, but the owner never shows up in ``gc.get_referrers(obj.__dict__)``. """ __slots__ = () @property def __dict__(self): return _hidden_dict def test_assert_no_instances_reports_referrer_chain(): """A clean class passes; a module-global leak is reported, with extra_info.""" obj = _Leaky() del obj assert_no_instances(_Leaky, when="test") global _leaked _leaked = _Leaky() with pytest.raises(AssertionError, match=rf"Found 1 {__name__}\._Leaky @ test"): assert_no_instances(_Leaky, when="test") with pytest.raises(AssertionError, match="custom-marker"): assert_no_instances( _Leaky, when="test", extra_info=lambda obj: ["custom-marker"] ) def test_gc_collect_once_dedupes(request): """A second call with the same request is a no-op.""" testing.gc_collect_once(request) assert request.node._refleak_gc_collected is True testing.gc_collect_once(request) # does not raise, still a no-op def test_referrer_chain_finds_referrers(): """A list holding an object shows up as one of its referrers.""" obj = _Leaky() holder = [obj] lines, has_referrers = testing.referrer_chain(obj) assert has_referrers assert any("list" in line for line in lines) del holder def test_assert_no_instances(): """Test Some basics of our assertions.""" class _Foo: pass _holder = {"key": _Foo()} with pytest.raises(AssertionError, match="after closing"): assert_no_instances(_Foo, "after closing") del _holder assert_no_instances(_Foo, "test") holder = _Foo() # A bare local is invisible: its only reference lives in the calling # frame's fast locals, which gc.get_referrers cannot see (and frames are # deliberately skipped anyway), so it is not reported as a leak. assert_no_instances(_Foo, "after closing", objs=[holder]) assert_no_instances(_Foo, "after closing") # Held by a GC-visible container -> reported, including via objs=... container = [holder] with pytest.raises(AssertionError, match="after closing"): assert_no_instances(_Foo, "after closing", objs=[holder]) with pytest.raises(AssertionError, match="after closing"): assert_no_instances(_Foo, "after closing") del container assert_no_instances(_Foo, "test") def test_cell_reports_closure_owner(): """A closure cell keeping an instance alive is named after its function. An enclosing scope that pulls a reference into an inner function keeps the object alive via a cell (possibly in a cycle); the failure message should name the closing-over function and variable, and recursion should continue from that function to find what anchors *it*. """ obj = _Leaky() def _inner(): return obj # closes over obj -> a cell now refers to it assert _inner() is obj with pytest.raises(AssertionError) as exc_info: assert_no_instances(_Leaky, when="test") msg = str(exc_info.value) # the cell is named after the function closing over it and its variable; # its contents would just repeat the referent, so the closure length is # summarized instead (as for the other container types) assert "_inner.__closure__['obj']: cell = " in msg # obj is a cell variable of *this* frame (that's what the closure closes # over), so the cell stays alive -- and visible -- until obj is rebound, # which drops the instance from the cell. del exc_info, _inner obj = None assert_no_instances(_Leaky, when="test") def test_describe_referrer_ownerless_cell(): """An empty (cleared) cell with no owning closure must render safely.""" cell = types.CellType() holder = [cell] # a non-tuple referrer of the cell decoy = (cell,) # a tuple referrer that is no function's __closure__ desc, next_obj = _describe_referrer(cell, None) assert desc.startswith("cell = ") assert next_obj is cell del holder, decoy def test_describe_referrer_function_and_module(): """Function and module referrers are described by their qualified names.""" desc, next_obj = _describe_referrer(test_referrer_chain_finds_referrers, None) assert desc.startswith("test_referrer_chain_finds_referrers: function = ") assert next_obj is test_referrer_chain_finds_referrers desc, next_obj = _describe_referrer(sys, None) assert desc.startswith("sys: module = "): assert_no_instances(_Leaky, when="test") _holder_list = None _holder_dict = {"k": _Leaky()} with pytest.raises(AssertionError, match=r"_holder_dict\['k'\]: dict = "): assert_no_instances(_Leaky, when="test") def test_module_globals_map_skips_broken_modules(monkeypatch): """Broken sys.modules entries (None, exploding __dict__) are skipped.""" class _EvilDict(dict): def items(self): """Raise instead of iterating.""" raise RuntimeError("boom") class _FakeModule: pass fake = _FakeModule() fake.__dict__ = _EvilDict(some_attr=1) monkeypatch.setitem(sys.modules, "refleak_test_none_module", None) monkeypatch.setitem(sys.modules, "refleak_test_evil_module", fake) global _leaked _leaked = _Leaky() # building the map survives the broken entries and still finds globals gmap = _module_globals_map() assert gmap[id(_leaked)] == f"{__name__}._leaked" assert id(fake) not in gmap def test_safe_repr_failure(): """A raising __repr__ (e.g. a dead C++ proxy) is reported, not propagated.""" class _BadRepr: def __repr__(self): """Raise unconditionally.""" raise ValueError("nope") assert _safe_repr(_BadRepr()) == "" def test_key_suffix(): """Container suffixes, including dict-key hits and not-found fallbacks.""" obj = _Leaky() assert _key_suffix([1, obj], obj) == "[1]" assert _key_suffix((obj,), obj) == "[0]" assert _key_suffix({"a": obj}, obj) == "['a']" assert _key_suffix({obj: 1}, obj) == "-key" assert _key_suffix([1, 2], obj) == "" assert _key_suffix({"a": 1}, obj) == "" assert _key_suffix(42, obj) == "" def test_assert_no_instances_isinstance_raises(): """A cls whose isinstance check explodes (like a weakref) is tolerated.""" class _Meta(type): def __instancecheck__(cls, instance): """Raise for any isinstance check.""" raise TypeError("boom") class _Weird(metaclass=_Meta): pass assert_no_instances(_Weird, "test", objs=[object()]) def test_version_fallback(): """__version__ falls back to 0.0.0 when package metadata is missing.""" real = refleak.__version__ with pytest.MonkeyPatch.context() as mp: def _raise(name): raise importlib.metadata.PackageNotFoundError(name) mp.setattr(importlib.metadata, "version", _raise) importlib.reload(refleak) assert refleak.__version__ == "0.0.0" importlib.reload(refleak) assert refleak.__version__ == real def test_describe_referrer_kinds(): """Bound methods, anonymous objects, and named globals render correctly.""" class _A: def _meth(self): """No-op.""" a = _A() m = a._meth desc, next_obj = _describe_referrer(m, a) assert "._A._meth: method = <" in desc assert next_obj is m gen = (i for i in ()) desc, next_obj = _describe_referrer(gen, None) # anonymous fallback: the name is just the type, so it is not repeated assert desc.startswith("generator = = 3.11. assert vars(parent) == {"child": parent.child} with pytest.raises(AssertionError) as exc_info: assert_no_instances(_Leaky, when="test") msg = str(exc_info.value) assert "_Parent.__dict__['child']: dict = " in msg del parent.child assert_no_instances(_Leaky, when="test") def test_two_leaks_render_with_ids(): """Two survivors render as two sections, each id-tagged. Run with ``pytest -s`` to eyeball the full rendered failure message. """ global _leaked, _holder_list _leaked = _Leaky() _holder_list = [_Leaky()] with pytest.raises(AssertionError) as exc_info: assert_no_instances(_Leaky, when="test") msg = str(exc_info.value) print(msg) assert f"Found 2 {__name__}._Leaky @ test:" in msg # section headers use the short class name (full name is in the summary) assert f"\n_Leaky @ 0x{id(_leaked):x}:" in msg assert f"\n_Leaky @ 0x{id(_holder_list[0]):x}:" in msg def test_traceback_frame_leak_detected(): """An object alive only via a stored traceback's frame is reported. Live-stack frames are machinery and stay hidden, but a frame kept alive by a saved exception is a real (and classic) leak anchor that used to be a silent false negative. """ def _bad(): w = _Leaky() assert w is not None raise RuntimeError("boom") global _saved_exc try: _bad() except RuntimeError as exc: _saved_exc = exc with pytest.raises(AssertionError) as exc_info: assert_no_instances(_Leaky, when="test") msg = str(exc_info.value) assert "frame of " in msg assert "_bad: frame = " in msg assert "_saved_exc" in msg # the walk continues to the actual anchor _saved_exc = None assert_no_instances(_Leaky, when="test") def test_attr_names_for_instance_referrers(): """Instance referrers name the attribute holding the referent.""" class _DictHolder: other: str | None kid: _Leaky | None class _SlotHolder: child: _Leaky | None __slots__ = "child" # str (not tuple) form of __slots__ class _TwoSlots: second: _Leaky | None __slots__ = ("first", "second") obj = _Leaky() d = _DictHolder() d.other = "unrelated" # scanned (and passed over) before the match d.kid = obj desc, next_obj = _describe_referrer(d, obj) assert "_DictHolder.kid: " in desc assert next_obj is d s = _SlotHolder() s.child = obj desc, _ = _describe_referrer(s, obj) assert "_SlotHolder.child: " in desc t = _TwoSlots() t.second = obj # "first" left unset -> AttributeError path desc, _ = _describe_referrer(t, obj) assert "_TwoSlots.second: " in desc def test_empty_when_omitted(): """An empty ``when`` doesn't leave a dangling ' @ ' in the message.""" class _Foo: pass _holder = {"key": _Foo()} with pytest.raises(AssertionError, match=r"Found 1 .*_Foo:\n"): assert_no_instances(_Foo) del _holder def test_fullname_module_vs_class(): """_fullname distinguishes modules from instances of user-defined classes.""" assert _fullname(testing) == "refleak.testing" assert _fullname(_Leaky()) == f"{__name__}._Leaky" assert _fullname(_Leaky) == f"{__name__}._Leaky" # classes name themselves def test_dict_owner_objs_scan_fallback(): """A dict owner invisible to gc.get_referrers is found by scanning objs.""" global _hidden_dict owner = _GhostlyOwner() _hidden_dict = {"child": _Leaky()} # the referrers-based scan really cannot see the owner assert not any(r is owner for r in gc.get_referrers(_hidden_dict)) with pytest.raises(AssertionError) as exc_info: assert_no_instances(_Leaky, when="test") msg = str(exc_info.value) assert f"{__name__}._GhostlyOwner.__dict__['child']: dict = " in msg del owner def test_orphan_dict_keys_preview_and_dead_end(): """An unowned dict previews its keys and marks the gc-invisible anchor.""" obj = _Leaky() d = {"the_attr": obj, "other_attr": 1} lines, has_referrers = testing.referrer_chain(obj) assert has_referrers text = "\n".join(lines) # sibling keys hint at whose attribute dict this might be (e.g. a VTK # "ghost" __dict__ whose owner's Python wrapper is already dead) assert "dict['the_attr']: dict = " in text # the dict itself has no gc-visible referrers (only the live test frame) assert "(no gc-visible referrers)" in text del d def test_snapshot_lifecycle(): """Pre-existing instances are ignored; new survivors are reported; ids only.""" global _leaked, _holder_list _leaked = _Leaky() # pre-dates the snapshot with pytest.raises(AssertionError): # zero-instances check does fail assert_no_instances(_Leaky, when="test") snap = Snapshot(_Leaky) snap.assert_no_new(when="test") # alive, but nothing *new* _holder_list = [_Leaky()] with pytest.raises(AssertionError) as exc_info: snap.assert_no_new(when="test") msg = str(exc_info.value) assert f"Found 1 new {__name__}._Leaky object @ test:" in msg assert f"\n_Leaky @ 0x{id(_holder_list[0]):x}:" in msg assert "_holder_list[0]" in msg # the referrer chain names the module global del exc_info # only ids are stored: dropping the leaks makes both checks pass with the # snapshot itself still alive _leaked = _holder_list = None snap.assert_no_new(when="test") assert_no_instances(_Leaky, when="test") def test_snapshot_match_forms_and_labels(): """Types, tuples, and predicates all match; labels and plurals render.""" snap_label = Snapshot(_Leaky, label="VTK") snap_tuple = Snapshot((_Leaky,)) snap_pred = Snapshot(lambda obj: isinstance(obj, _Leaky)) global _holder_list _holder_list = [_Leaky(), _Leaky()] with pytest.raises(AssertionError, match=r"Found 2 new VTK objects @ test:"): snap_label.assert_no_new(when="test") with pytest.raises(AssertionError, match=r"Found 2 new \(.*\._Leaky\) objects @"): snap_tuple.assert_no_new(when="test") # a callable adds no adjective to the summary by default with pytest.raises(AssertionError, match=r"Found 2 new objects @ test:"): snap_pred.assert_no_new(when="test") def test_snapshot_match_robustness(): """Bad match args fail at construction; a raising predicate is a miss.""" with pytest.raises(TypeError, match="tuple must contain only types"): Snapshot((_Leaky, 42)) with pytest.raises(TypeError, match="must be a type"): Snapshot(42) class _Explosive: pass def match(obj): if isinstance(obj, _Explosive): raise ValueError("boom") return isinstance(obj, _Leaky) landmine = _Explosive() snap = Snapshot(match) global _leaked _leaked = _Leaky() with pytest.raises(AssertionError, match="Found 1 new object @ test:"): snap.assert_no_new(when="test") del landmine def test_snapshot_objs_extra_info_and_empty_when(): """objs= and extra_info= pass through; empty when leaves no dangling @.""" snap = Snapshot(_Leaky, collect=False) global _leaked _leaked = _Leaky() with pytest.raises(AssertionError, match="custom-marker"): snap.assert_no_new(when="test", extra_info=lambda obj: ["custom-marker"]) with pytest.raises(AssertionError, match=r"Found 1 new .*\._Leaky object:\n"): snap.assert_no_new(objs=[_leaked]) # a caller-provided objs= at construction is what gets recorded snap_pre = Snapshot(_Leaky, objs=[_leaked]) snap_pre.assert_no_new(when="test") # the leak pre-dates snap_pre snap_empty = Snapshot(_Leaky, objs=[]) with pytest.raises(AssertionError, match="Found 1 new"): snap_empty.assert_no_new(when="test") def test_snapshot_freeze_catches_reused_address(): """A leak allocated at a dead object's address is caught only by freezing. CPython's allocator hands back the most recently freed block of a size class, so a new object created right after a same-sized one died lands at that same address -- and an id-based snapshot, which knows objects only by address, cannot tell it apart from the object it replaced. Freezing has no ids to collide. """ global _holder_list # Get a pool of this size class to the front of the allocator's free # lists first: whether an *arbitrary* freed block is handed straight back # depends on what earlier tests left behind, and this test is about what # happens once it is. warmup = [_Leaky() for _ in range(64)] del warmup doomed = _Leaky() doomed_id = id(doomed) # objs= (rather than a full scan) so that recording the id disturbs the # allocator as little as possible -- reuse of doomed's block below is the # whole point of the test ids_snap = Snapshot(_Leaky, objs=[doomed], collect=False) snap = Snapshot(_Leaky, freeze=True) assert gc.get_freeze_count() > _IMMORTAL_FREEZE_COUNT del doomed _holder_list = [_Leaky()] # allocated straight into the freed block leaked_id = id(_holder_list[0]) with pytest.raises(AssertionError) as exc_info: snap.assert_no_new(when="test") msg = str(exc_info.value) assert f"Found 1 new {__name__}._Leaky object @ test:" in msg assert f"\n_Leaky @ 0x{leaked_id:x}:" in msg assert "_holder_list[0]" in msg # even the failing path thaws assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT del exc_info # The leak really did land on the dead object's address ... assert leaked_id == doomed_id # ... which is precisely why the id-based snapshot sees nothing new. ids_snap.assert_no_new(when="test") def test_snapshot_freeze_thaws_before_reporting(): """A survivor anchored by a pre-freeze container is still reported. ``gc.get_referrers()`` skips the permanent generation, so while frozen this leak's only anchor is invisible and the survivor looks referrer-less -- i.e. not a leak at all. The check must therefore thaw before building the report. """ global _holder_list _holder_list = [] # the "registry" pre-dates (and so gets frozen by) the snapshot snap = Snapshot(_Leaky, freeze=True) leak = _Leaky() _holder_list.append(leak) # while frozen the anchor is invisible, which is exactly the trap assert not any(r is _holder_list for r in gc.get_referrers(leak)) del leak with pytest.raises(AssertionError) as exc_info: snap.assert_no_new(when="test") msg = str(exc_info.value) assert f"{__name__}._holder_list[0]: list = " in msg # ... and it is the *only* anchor: the frozen-time gc.get_objects() list # must not survive into the post-thaw rescan and show up as a bogus # anonymous-list referrer of its own survivors. assert "├──" not in msg assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT del exc_info def test_snapshot_freeze_nests(): """An inner freeze-mode snapshot must not thaw the heap out from under the outer. ``gc.unfreeze()`` empties the whole permanent generation, so only the outermost snapshot may call it (nested in-process pytest sessions really do produce this shape). ``gc.get_freeze_count()`` is a live census of the permanent generation, not a count of freeze calls: it drops whenever a frozen object is refcount-deallocated, so "still frozen" can only be asserted as "more than the pre-freeze baseline", never as an exact (or even unchanged) number. And "thawed" is only "at most the interpreter's own immortals" (see ``_IMMORTAL_FREEZE_COUNT``), not exactly zero. """ baseline = gc.get_freeze_count() outer = Snapshot(_Leaky, freeze=True) assert gc.get_freeze_count() > baseline inner = Snapshot(_Leaky, freeze=True) inner.assert_no_new(when="inner") # nothing new: passes, and thaws inner assert gc.get_freeze_count() > baseline # ... but the heap is still frozen outer.assert_no_new(when="outer") assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT def test_snapshot_thaw_is_idempotent(): """thaw() can be called any number of times, on any snapshot.""" # The id-based constructor's gc.collect() runs on an unfrozen heap, which # on 3.12 parks the immortals -- exactly why "thawed" is <=, never == 0. Snapshot(_Leaky).thaw() # a no-op for an id-based snapshot assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT snap = Snapshot(_Leaky, freeze=True) assert gc.get_freeze_count() > _IMMORTAL_FREEZE_COUNT snap.thaw() assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT snap.thaw() # the second thaw must not unbalance the nesting counter assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT other = Snapshot(_Leaky, freeze=True) # so this one still freezes ... assert gc.get_freeze_count() > _IMMORTAL_FREEZE_COUNT other.assert_no_new(when="test") # ... and still thaws assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT def test_snapshot_freeze_rejects_objs(): """objs= is meaningless when freezing, and rejecting it must not freeze.""" with pytest.raises(TypeError, match="objs= cannot be combined with freeze=True"): Snapshot(_Leaky, freeze=True, objs=[]) # validation happens before any freeze assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT def test_snapshot_freeze_second_check_is_zero_tolerance(): """Once thawed, the empty before-set makes a re-check strict, not blind. A freeze-mode snapshot is single-use; a second check sees the (now unfrozen) pre-existing objects too, so it over-reports rather than silently missing anything. """ global _leaked _leaked = _Leaky() # pre-dates the snapshot snap = Snapshot(_Leaky, freeze=True) snap.assert_no_new(when="test") # passes: nothing new with pytest.raises(AssertionError, match="Found 1 new"): snap.assert_no_new(when="test") assert gc.get_freeze_count() <= _IMMORTAL_FREEZE_COUNT