Skip to content

Commit e3ecbca

Browse files
authored
fix(tests): stop conftest leaking temp dirs and being imported twice (#1559)
## Why A full test run left ~12.4k abandoned directories in `/tmp` and, separately, silently disabled the per-test isolation `tests/conftest.py` promises. Both traced back to the same file. On this machine the leak reached 973k entries in two days of uptime and exhausted the tmpfs inode table — `mkdir` then fails with `ENOSPC` while `df` still reports 5 GB free, which breaks every tool that needs a temp file. `/tmp` is only aged at boot (`q! /tmp … 10d`), so nothing reclaims it in between. ## What changed **Temp dirs are released again.** `_reset_decky_mock_paths` created two `tempfile.mkdtemp()` directories per test and never removed them; three more leaked per process at import time. `mkdtemp` leaves the directory behind by design. The per-test pair is now dropped on teardown, the import-time trio via `atexit` — a session-scoped fixture would not fire for a run that collects no tests. **`conftest.py` is no longer imported twice.** It puts `tests/` on `sys.path`, so `from conftest import ...` in 13 modules resolved to a *second* module object beside pytest's `tests.conftest`. Both ran the module body, and the later one won `sys.modules["decky"] = mock_decky`. Since fixtures are registered only from pytest's copy, the autouse fixture refreshed a mock nothing observed, while production code reading `decky.DECKY_PLUGIN_SETTINGS_DIR` saw the shadow's import-time directory for the whole session: ``` tests/adapters alone -> fresh dir per test the same file + tests/services -> one shared dir for every test ``` Behaviour that depends on the test selection. Nothing relied on it yet — the only test reaching `Plugin._main()` patches `main.bootstrap`, so the paths never drive real I/O — so this was a trap waiting for the first persistence test, not an active failure. The shared constructors moved to `tests/_factories.py`, matching the helper modules the suite already uses (`tests/contract/_harness.py`, `tests/services/saves/_helpers.py`). Switching to `prepend` import mode would also fix the root cause, but is unavailable: 17 test basenames repeat across directories and only 6 sit in packages. **Dead config removed.** `pytest.ini` and `[tool.pytest.ini_options]` both declared `asyncio_mode`; `pytest.ini` wins, so the pyproject block never took effect. ## Not moved to `tmp_path` Deliberate. Adapter tests assert on the exact contents of their `tmp_path` (`listdir(...) == []`), and `tests/contract/_harness.py` already owns `tmp_path/settings` and `tmp_path/runtime`. `tmp_path` would be the idiomatic choice in the abstract and the wrong one here. ## Verification - 6210 Python tests pass; the now-*effective* isolation breaks none of them, confirming the defect was latent - `/tmp` delta is 0 after a full run, a single file, a subset with direct conftest imports, and a path collecting no tests (each previously leaked) - `mise run gate` green end to end docs: N/A — test infrastructure and tooling only; no architecture, data-flow, or user-facing behaviour change.
1 parent 51a333c commit e3ecbca

16 files changed

Lines changed: 138 additions & 91 deletions

pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,3 @@ select = [
6363
[tool.ruff.lint.isort]
6464
known-first-party = ["lib", "domain", "services", "adapters"]
6565
known-third-party = ["_vendor", "decky"]
66-
67-
[tool.pytest.ini_options]
68-
asyncio_mode = "auto"

tests/_factories.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Construction helpers shared across the suite.
2+
3+
These live outside ``conftest.py`` on purpose. A conftest is imported by
4+
pytest under its own module name; importing it a second time by plain name
5+
(`from conftest import ...`) creates a *separate* module object that re-runs
6+
the module body — including ``sys.modules["decky"] = mock_decky``, which
7+
would then shadow the mock whose paths the autouse fixtures refresh.
8+
Anything a test module needs to import belongs here instead.
9+
10+
Import as ``from _factories import _make_retry`` — ``tests/`` is on the path
11+
via the root conftest, the same way ``fakes/`` is reached.
12+
"""
13+
14+
from typing import Any
15+
from unittest.mock import MagicMock
16+
17+
18+
def _no_retry(fn, *a, **kw):
19+
"""Pass-through Retry side_effect: invoke the wrapped callable once, no backoff."""
20+
return fn(*a, **kw)
21+
22+
23+
def _make_retry():
24+
"""Build a Retry ``MagicMock`` that runs ``with_retry`` callables exactly once
25+
and reports every exception as non-retryable. Used everywhere services
26+
take a ``Retry`` Protocol injection in tests."""
27+
retry = MagicMock()
28+
retry.with_retry.side_effect = _no_retry
29+
retry.is_retryable.return_value = False
30+
return retry
31+
32+
33+
def _make_testable_plugin():
34+
"""Return a TestablePlugin instance with test-only attributes declared.
35+
36+
Pre-populates ``_migration_service`` with a non-pending MagicMock so the
37+
``@migration_blocked`` decorator passes through (it requires the service and
38+
raises RuntimeError if it is unwired) in tests that don't otherwise wire
39+
migration state. Tests that exercise the block can override
40+
``is_retrodeck_migration_pending`` per-test.
41+
42+
Also pre-wires a no-op ``_debug_logger`` so any service that consumes
43+
``Plugin._log_debug`` (which forwards through ``_debug_logger``) works
44+
out of the box. Tests that want to assert on debug-log behaviour can
45+
override ``_debug_logger`` after construction (e.g. with the real
46+
``SettingsAwareDebugLogger`` bound to a settings dict they control).
47+
"""
48+
# Import here to ensure decky mock is already installed
49+
from main import Plugin
50+
51+
class TestablePlugin(Plugin):
52+
"""Plugin subclass that declares test-only attributes for type safety.
53+
54+
Genuinely test-fixture-only attributes live here: ``_fake_api``,
55+
``_resolve_system``, ``_save_settings``, plus the Unit-of-Work
56+
handles tests seed and assert against (``_uow``, ``_uow_factory``)
57+
and the per-test ``_tmp_path`` scratch dir. Test-fixture handles
58+
shared with production wiring (``_state``, ``_http_adapter``, ...)
59+
are declared on ``Plugin`` itself as ``Any``-typed annotation slots
60+
so test-only construction paths type-check uniformly.
61+
``_save_settings`` is a test-only handle for the settings dict tests
62+
thread into ``SaveService`` / ``PlaytimeService``; production threads
63+
its settings store as ``self.settings``, never under this name.
64+
"""
65+
66+
_fake_api: Any
67+
_resolve_system: Any
68+
_save_settings: Any
69+
_uow: Any
70+
_uow_factory: Any
71+
_tmp_path: Any
72+
_core_info: Any
73+
_platform_core_reader: Any
74+
_active_core: Any
75+
_m3u_supported: Any
76+
_renderer_rss: Any
77+
_renderer_gc: Any
78+
79+
instance = TestablePlugin()
80+
instance._migration_service = MagicMock()
81+
instance._migration_service.is_retrodeck_migration_pending.return_value = False
82+
instance._debug_logger = lambda msg: None
83+
return instance

tests/conftest.py

Lines changed: 42 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import atexit
12
import logging
23
import os
4+
import shutil
35
import sys
46
import tempfile
5-
from typing import Any
67
from unittest.mock import AsyncMock, MagicMock
78

89
import pytest
@@ -27,87 +28,42 @@
2728
sys.path.insert(0, _tests_root)
2829

2930

31+
# Import-time temp dirs, so the mock module is complete before anything imports
32+
# main. The settings/runtime pair is replaced per test by `_reset_decky_mock_paths`;
33+
# the log dir lives for the whole process.
34+
_process_settings_dir = tempfile.mkdtemp()
35+
_process_runtime_dir = tempfile.mkdtemp()
36+
_process_log_dir = tempfile.mkdtemp()
37+
38+
39+
@atexit.register
40+
def _cleanup_process_temp_dirs() -> None:
41+
"""Drop the import-time temp dirs — ``mkdtemp`` never cleans up after itself.
42+
43+
Deliberately ``atexit`` rather than a session-scoped fixture: a fixture
44+
only tears down once at least one test is collected, so a run that
45+
collects nothing — pointing pytest at a directory that holds only
46+
fixtures or vectors — would leak all three. atexit also stays correct
47+
if this module is ever executed more than once, since each execution
48+
releases exactly what it created.
49+
"""
50+
for path in (_process_settings_dir, _process_runtime_dir, _process_log_dir):
51+
shutil.rmtree(path, ignore_errors=True)
52+
53+
3054
# Create mock decky module before any imports of main
3155
mock_decky = MagicMock()
3256
mock_decky.DECKY_PLUGIN_DIR = _project_root
33-
mock_decky.DECKY_PLUGIN_SETTINGS_DIR = tempfile.mkdtemp()
34-
mock_decky.DECKY_PLUGIN_RUNTIME_DIR = tempfile.mkdtemp()
35-
mock_decky.DECKY_PLUGIN_LOG_DIR = tempfile.mkdtemp()
57+
mock_decky.DECKY_PLUGIN_SETTINGS_DIR = _process_settings_dir
58+
mock_decky.DECKY_PLUGIN_RUNTIME_DIR = _process_runtime_dir
59+
mock_decky.DECKY_PLUGIN_LOG_DIR = _process_log_dir
3660
mock_decky.DECKY_USER_HOME = os.path.expanduser("~")
3761
mock_decky.logger = logging.getLogger("test_romm")
3862
mock_decky.emit = AsyncMock()
3963

4064
sys.modules["decky"] = mock_decky
4165

4266

43-
def _no_retry(fn, *a, **kw):
44-
"""Pass-through Retry side_effect: invoke the wrapped callable once, no backoff."""
45-
return fn(*a, **kw)
46-
47-
48-
def _make_retry():
49-
"""Build a Retry ``MagicMock`` that runs ``with_retry`` callables exactly once
50-
and reports every exception as non-retryable. Used everywhere services
51-
take a ``Retry`` Protocol injection in tests."""
52-
retry = MagicMock()
53-
retry.with_retry.side_effect = _no_retry
54-
retry.is_retryable.return_value = False
55-
return retry
56-
57-
58-
def _make_testable_plugin():
59-
"""Return a TestablePlugin instance with test-only attributes declared.
60-
61-
Pre-populates ``_migration_service`` with a non-pending MagicMock so the
62-
``@migration_blocked`` decorator passes through (it requires the service and
63-
raises RuntimeError if it is unwired) in tests that don't otherwise wire
64-
migration state. Tests that exercise the block can override
65-
``is_retrodeck_migration_pending`` per-test.
66-
67-
Also pre-wires a no-op ``_debug_logger`` so any service that consumes
68-
``Plugin._log_debug`` (which forwards through ``_debug_logger``) works
69-
out of the box. Tests that want to assert on debug-log behaviour can
70-
override ``_debug_logger`` after construction (e.g. with the real
71-
``SettingsAwareDebugLogger`` bound to a settings dict they control).
72-
"""
73-
# Import here to ensure decky mock is already installed
74-
from main import Plugin
75-
76-
class TestablePlugin(Plugin):
77-
"""Plugin subclass that declares test-only attributes for type safety.
78-
79-
Genuinely test-fixture-only attributes live here: ``_fake_api``,
80-
``_resolve_system``, ``_save_settings``, plus the Unit-of-Work
81-
handles tests seed and assert against (``_uow``, ``_uow_factory``)
82-
and the per-test ``_tmp_path`` scratch dir. Test-fixture handles
83-
shared with production wiring (``_state``, ``_http_adapter``, ...)
84-
are declared on ``Plugin`` itself as ``Any``-typed annotation slots
85-
so test-only construction paths type-check uniformly.
86-
``_save_settings`` is a test-only handle for the settings dict tests
87-
thread into ``SaveService`` / ``PlaytimeService``; production threads
88-
its settings store as ``self.settings``, never under this name.
89-
"""
90-
91-
_fake_api: Any
92-
_resolve_system: Any
93-
_save_settings: Any
94-
_uow: Any
95-
_uow_factory: Any
96-
_tmp_path: Any
97-
_core_info: Any
98-
_platform_core_reader: Any
99-
_active_core: Any
100-
_m3u_supported: Any
101-
_renderer_rss: Any
102-
_renderer_gc: Any
103-
104-
instance = TestablePlugin()
105-
instance._migration_service = MagicMock()
106-
instance._migration_service.is_retrodeck_migration_pending.return_value = False
107-
instance._debug_logger = lambda msg: None
108-
return instance
109-
110-
11167
@pytest.fixture
11268
def fake_romm_api():
11369
"""Function-scoped ``FakeRommApi`` instance.
@@ -141,10 +97,21 @@ def _reset_decky_mock_paths():
14197
14298
Fresh ``DECKY_PLUGIN_SETTINGS_DIR`` and ``DECKY_PLUGIN_RUNTIME_DIR``
14399
per test prevents cross-test pollution from persistence-touching
144-
tests.
100+
tests. Both are removed again on teardown — ``mkdtemp`` leaves the
101+
directory behind by design, and two leaked dirs per test across a
102+
suite this size exhaust the tmpfs inode table in days, not months.
103+
104+
They deliberately do not live under ``tmp_path``: adapter tests assert
105+
on the exact contents of their ``tmp_path`` (``listdir(...) == []``),
106+
and ``tests/contract`` already owns ``tmp_path/settings`` and
107+
``tmp_path/runtime``.
145108
"""
146109
mock_decky.DECKY_USER_HOME = os.path.expanduser("~")
147110
mock_decky.DECKY_PLUGIN_DIR = _project_root
148-
mock_decky.DECKY_PLUGIN_SETTINGS_DIR = tempfile.mkdtemp()
149-
mock_decky.DECKY_PLUGIN_RUNTIME_DIR = tempfile.mkdtemp()
111+
settings_dir = tempfile.mkdtemp()
112+
runtime_dir = tempfile.mkdtemp()
113+
mock_decky.DECKY_PLUGIN_SETTINGS_DIR = settings_dir
114+
mock_decky.DECKY_PLUGIN_RUNTIME_DIR = runtime_dir
150115
yield
116+
shutil.rmtree(settings_dir, ignore_errors=True)
117+
shutil.rmtree(runtime_dir, ignore_errors=True)

tests/services/library/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import pytest
1616

1717
# conftest.py patches decky before this import; use _make_testable_plugin for test-only attrs
18-
from conftest import _make_testable_plugin
18+
from _factories import _make_testable_plugin
1919
from fakes.fake_core_info_provider import FakeCoreInfoProvider
2020
from fakes.fake_disc_resolver import FakeDiscResolver
2121
from fakes.fake_platform_core_reader import FakePlatformCoreReader

tests/services/saves/_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from datetime import UTC, datetime
77
from typing import Any
88

9-
from conftest import _make_retry
9+
from _factories import _make_retry
1010
from fakes.fake_active_core_resolver import FakeActiveCoreResolver
1111
from fakes.fake_hostname_reader import FakeHostnameReader
1212
from fakes.fake_machine_id_reader import FakeMachineIdReader

tests/services/saves/sync_engine/test_devices.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import logging
99

1010
import pytest
11-
from conftest import _make_retry
11+
from _factories import _make_retry
1212
from fakes.fake_hostname_reader import FakeHostnameReader
1313
from fakes.fake_machine_id_reader import FakeMachineIdReader
1414
from fakes.fake_save_api import FakeSaveApi

tests/services/test_achievements.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77
# conftest.py patches decky before this import; use _make_testable_plugin for test-only attrs
8-
from conftest import _make_testable_plugin
8+
from _factories import _make_testable_plugin
99
from fakes.fake_active_core_resolver import FakeActiveCoreResolver
1010
from fakes.fake_disc_resolver import FakeDiscResolver
1111
from fakes.fake_renderer_gc import FakeRendererGc

tests/services/test_downloads.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99

1010
# conftest.py patches decky before this import; use _make_testable_plugin for test-only attrs
11-
from conftest import _make_testable_plugin
11+
from _factories import _make_testable_plugin
1212
from fakes.fake_core_info_provider import FakeCoreInfoProvider
1313
from fakes.fake_disc_resolver import FakeDiscResolver
1414
from fakes.fake_platform_core_reader import FakePlatformCoreReader

tests/services/test_firmware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99

1010
# conftest.py patches decky before this import; use _make_testable_plugin for test-only attrs
11-
from conftest import _make_testable_plugin
11+
from _factories import _make_testable_plugin
1212
from fakes.fake_active_core_resolver import FakeActiveCoreResolver
1313
from fakes.fake_core_info_provider import FakeCoreInfoProvider
1414
from fakes.fake_disc_resolver import FakeDiscResolver

tests/services/test_game_detail.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77

88
# conftest.py patches decky before this import; use _make_testable_plugin for test-only attrs
9-
from conftest import _make_retry, _make_testable_plugin
9+
from _factories import _make_retry, _make_testable_plugin
1010
from fakes.fake_active_core_resolver import FakeActiveCoreResolver
1111
from fakes.fake_core_info_provider import FakeCoreInfoProvider
1212
from fakes.fake_disc_resolver import FakeDiscResolver

0 commit comments

Comments
 (0)