Skip to content

Global base-path cache leaks between tests #385

Closed

Description

Summary

gerrit_urls._BASE_PATH_CACHE is a module-level dict with no test isolation.
Tests that populate it leak state into every later test in the same process.

Detail

src/github2gerrit/gerrit_urls.py:29:

_BASE_PATH_CACHE: dict[str, str] = {}

Written by _infer_and_cache_base_path (line 96, from lines 127 and 149) and
directly at lines 187 and 194. Nothing resets it between tests except
tests/test_gerrit_urls_more.py, which clears it explicitly at lines 23 and
212 — an opt-in that other files do not repeat.

gerrit.example.org is used as a fixture host across roughly twenty test
files, and tests/test_gerrit_urls_more.py:195-229 deliberately caches the
base path r for that host via mocked redirects. Any later test that builds a
URL for the same host then gets /r/ inserted.

Observed symptom

Seen intermittently during work on #380 / #381:

FAILED tests/test_core_gerrit_rest_results.py::test_query_gerrit_results_success_base_path

At index 0 diff:
  'https://gerrit.example.org/r/c/releng/builder/+/12345' !=
  'https://gerrit.example.org/c/releng/builder/+/12345'

The test guards the environment carefully — it deletes GERRIT_HTTP_BASE_PATH,
GERRIT_HTTP_USER and GERRIT_HTTP_PASSWORD via monkeypatch — but has no way
to guard the module-level cache.

The failure did not reproduce on 12 consecutive full-suite runs of the final
tree in #384, so it is not tied to that change. It is latent, and adding or
reordering tests can surface it at any time.

Suggested fix

Add an autouse fixture in tests/conftest.py that clears the cache around every
test:

@pytest.fixture(autouse=True)
def _reset_base_path_cache():
    from github2gerrit import gerrit_urls
    gerrit_urls._BASE_PATH_CACHE.clear()
    yield
    gerrit_urls._BASE_PATH_CACHE.clear()

The explicit clears in tests/test_gerrit_urls_more.py then become redundant.

Worth auditing for sibling cases while in the area: ssh_config_parser has
_get_cached_git_user_email and _get_respect_user_ssh_setting, which cache
process-wide for the same reason and carry the same isolation risk.

Acceptance criteria

  • Base-path cache resets between tests
  • Other process-wide caches audited for the same leak
  • Redundant per-file clears removed

Activity

  1. ModeSevenIndustrialSolutions commented on Aug 24, 2026

    @ModeSevenIndustrialSolutions
    MemberAuthor

    Root cause of the intermittency identified: pytest-randomly is installed and active.

    plugins: snapshot-0.9.0, nbmake-1.5.5, dash-2.14.1, responses-0.5.1, mock-3.15.1,
    datafiles-3.0.0, anyio-4.9.0, hypothesis-6.144.0, click-1.1.0, xdist-3.8.0,
    randomly-4.0.1, asyncio-1.3.0, benchmark-5.2.0, rerunfailures-16.1, cov-7.0.0
    

    Test order is reshuffled on every run, so whether tests/test_gerrit_urls_more.py
    executes before tests/test_core_gerrit_rest_results.py — and therefore whether
    the cached r base path for gerrit.example.org is still present — is decided
    by the random seed.

    This explains the observed behaviour exactly: roughly one failure in four, with
    no relationship to the change under test, and non-reproducible on reruns of an
    identical tree.

    Two consequences worth noting:

    1. The failure is reproducible on demand with -p no:randomly and an explicit
      ordering, which makes this straightforward to write a regression test for:

      uv run pytest -p no:randomly \
        tests/test_gerrit_urls_more.py \
        tests/test_core_gerrit_rest_results.py
    2. Any other module-level cache carries the same exposure, and randomised
      ordering means such bugs surface as unattributable flakes rather than
      consistent failures. The ssh_config_parser caches mentioned above are worth
      checking on the same pass.

    The autouse-fixture fix proposed in the issue body still applies; pytest-randomly
    is the reason the latent bug becomes visible rather than the bug itself.

  2. ModeSevenIndustrialSolutions commented on Aug 24, 2026

    @ModeSevenIndustrialSolutions
    MemberAuthor

    Polluters identified precisely, and the fix is included in #384 rather than deferred — the new tests there changed the pytest-randomly shuffle enough to make this flake frequent, so leaving it would have destabilised CI.

    Instrumenting the cache with an autouse spy named the two writers:

    tests/test_gerrit_urls_more.py::test_discover_base_path_3xx_location_relative_and_absolute -> 'r'
    tests/test_gerrit_urls_more.py::test_discover_base_path_relative_location_without_leading_slash -> 'r'
    

    Both mock a 302 with a /r/... Location, which _infer_and_cache_base_path writes into _BASE_PATH_CACHE for gerrit.example.org. Neither cleans up, so every later test using that fixture host inherits a /r base path.

    Fixed with the autouse fixture proposed above, in tests/conftest.py, clearing on both sides of each test. Measured before and after over 8 randomised full-suite runs each: 1 failure before, 0 after.

    Remaining work for this issue, left open deliberately:

    • Audit the sibling process-wide caches (ssh_config_parser._get_cached_git_user_email, _get_respect_user_ssh_setting) for the same exposure.
    • Remove the now-redundant explicit _BASE_PATH_CACHE.clear() calls in tests/test_gerrit_urls_more.py (lines 23 and 212).
    • Consider whether module-level caches keyed by host belong behind an injectable dependency rather than module state, which would remove this class of bug rather than mopping up after it.
  3. ModeSevenIndustrialSolutions commented on Aug 24, 2026

    @ModeSevenIndustrialSolutions
    MemberAuthor

    Correction to my previous comment: the pytest-randomly attribution was wrong.

    That plugin is not a dependency of this repository. grep -rn randomly pyproject.toml uv.lock returns nothing, and:

    $ uv run python -c "import pytest_randomly"
    ModuleNotFoundError: No module named 'pytest_randomly'
    

    The plugin list I quoted came from /Users/mwatkins/.pyenv/shims/pytest, which is what uv run pytest resolves to on my machine because pytest is not a project dependency and uv run falls through to PATH. My pyenv installation has pytest-randomly globally. I read a local-environment detail as a repository fact; apologies for the noise.

    What this changes:

    • Test order is deterministic here and in CI. The leak is therefore latent rather than intermittently visible, which is worse for discoverability, not better — it will surface the day someone adds, renames or reorders a test module, with no apparent connection to their change.
    • The -p no:randomly reproduction recipe in my earlier comment does not apply. Reproduce by controlling module order directly instead.

    What does not change:

    • The two writers remain confirmed: test_discover_base_path_3xx_location_relative_and_absolute and test_discover_base_path_relative_location_without_leading_slash, both in tests/test_gerrit_urls_more.py, caching r for gerrit.example.org.
    • The autouse fixture in Fix!: Harden fork pull request handling #384 still fixes it, and its docstring now describes the leak as order-dependent without naming a plugin.
    • The remaining work listed previously stands: audit the ssh_config_parser caches, drop the now-redundant explicit clear() calls, and consider whether host-keyed module state should be injected rather than global.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcode-qualityUpdates to code-quality

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions