test: isolate DisplayManager globals across modules, and name the failure (#563)

Follow-up to #562. That commit fixed the actual cause of the intermittent
15-test failure in test_display_dirty_tracking.py -- the emulator's fixed TCP
port 8888, a machine-wide singleton that a concurrent pytest process takes
away. This adds the two things that would have made it a five-minute
diagnosis instead of a long one, and closes the other door into the same
failure.

Confirmed the module is order-independent as it stands, on this checkout:

  pytest test/ -q, three times          115 failed / 4464 passed / 63 skipped,
                                        byte-identical failure sets, the
                                        module 21/21 passed each time
  module forced last (197 files first)  identical failure set
  module forced first                   identical failure set
  module after each of test_display_manager, test_display_controller,
    test_display_controller_vegas_tick, test_skin_system, test_sports_scroll,
    test_initial_update_budget, test_display_double_parity,
    test_initializing_screen                                     all pass
  four concurrent processes on the file                          21/21 each

And reproduced the original, to be sure the diagnosis in #562 is the whole
story. Holding 0.0.0.0:8888 from a separate process:

  HEAD's test/conftest.py        21 passed
  pre-#562 test/conftest.py      15 failed, 6 passed

The 15/6 split is not arbitrary: the six survivors are the only tests in the
file that never touch dm.matrix.

conftest.py: DisplayManager is a process-wide singleton and the RGBMatrix /
RGBMatrixOptions names it constructs through are module globals, bound once at
import. All three are shared by every test module in the run, so a module that
leaves an instance in _instance -- or leaves patch('src.display_manager.
RGBMatrix') standing -- changes what the NEXT module builds, invisibly, and
only in a full run. A module-scoped autouse fixture now resets the singleton
and restores either binding if a patch outlived its module. Module-scoped
rather than per-test so that files sharing one manager across their own tests
keep doing so; only the leak across the module boundary is cut. Autouse
fixtures are set up ahead of requested ones, so this is finalised after a
module's own DisplayManager fixture. Verified with a throwaway pair of probe
modules -- one leaks a patch and a singleton, the next asserts both are clean
-- which passed and were then removed.

test_display_dirty_tracking.py: _setup_matrix() swallows every construction
failure and falls back to matrix=None, so a broken environment arrived as
fifteen identical "'NoneType' object has no attribute 'SwapOnVSync'" errors
naming neither the fixture nor the cause. The fixture now fails once, and
says where to look; under a held port it reads

    DisplayManager fell back to matrix=None: RGBMatrix construction raised...
    Known causes: the emulator adapter losing a fixed TCP port to another
    process -- see pytest_configure in test/conftest.py -- or a
    patch('src.display_manager.RGBMatrix') leaked from an earlier test module.

with WinError 10048 in the captured log directly above it.

No regressions: full suite with both changes is 115 failed / 4464 passed /
63 skipped, failure set identical to the pre-change baseline. The 115 is the
pre-existing Windows-environment baseline (os.geteuid, POSIX modes, fcntl);
CI on Linux remains authoritative.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-12 16:13:38 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 59997594ac
commit 6b3028ad58
2 changed files with 70 additions and 1 deletions
+56 -1
View File
@@ -7,7 +7,7 @@ Provides common fixtures for mocking core components and test setup.
import pytest
import sys
from pathlib import Path
from unittest.mock import Mock, MagicMock
from unittest.mock import Mock, MagicMock, NonCallableMock
from typing import Dict, Any, Optional
# Add project root to path
@@ -70,6 +70,61 @@ def pytest_unconfigure(config):
shutil.rmtree(tmp_dir, ignore_errors=True)
# DisplayManager is a process-wide singleton, and the RGBMatrix /
# RGBMatrixOptions names it builds through are module globals bound once at
# import to either the emulator or the hardware library. All three are shared
# by every test module in the run, so a module that leaves a live instance in
# _instance -- or leaves patch('src.display_manager.RGBMatrix') standing --
# changes what the NEXT module constructs. None of that is visible when the
# affected file is run on its own; it surfaces as a full-suite failure that
# does not reproduce. The full-run failure diff is how a change is confirmed
# non-regressive, so it has to mean the same thing on every run.
_PRISTINE_MATRIX_BINDINGS = {}
@pytest.fixture(autouse=True, scope="module")
def _reset_display_manager_globals():
"""Undo any DisplayManager global state a test module leaves behind.
Autouse fixtures are set up ahead of the fixtures a test asks for, so this
one is finalised after them -- including after a module-scoped fixture that
owns a real DisplayManager (test_display_dirty_tracking.py's ``dm``).
Module-scoped rather than per-test: files that deliberately share one
manager across their own tests keep doing so; only the leak across the
module boundary is cut.
"""
# Record the real bindings on the way in, while no patch of this module's
# is active yet -- reading them on the way out would record the leak.
_remember_matrix_bindings()
yield
dm_mod = sys.modules.get("src.display_manager")
if dm_mod is None:
return # Module never imported it; nothing to reset.
# An instance left here is what the next module's DisplayManager() call
# gets back -- potentially one built against a MagicMock matrix.
dm_mod.DisplayManager._instance = None
dm_mod.DisplayManager._initialized = False
# Put a binding back if a patch outlived the module that started it.
# Restoring rather than failing: a leak reported against an innocent module
# later in the run is the diagnosis problem, not the fix for it.
for name, pristine in _PRISTINE_MATRIX_BINDINGS.items():
if isinstance(getattr(dm_mod, name, None), NonCallableMock):
setattr(dm_mod, name, pristine)
def _remember_matrix_bindings():
dm_mod = sys.modules.get("src.display_manager")
if dm_mod is None:
return
for name in ("RGBMatrix", "RGBMatrixOptions"):
current = getattr(dm_mod, name, None)
if current is not None and not isinstance(current, NonCallableMock):
_PRISTINE_MATRIX_BINDINGS.setdefault(name, current)
@pytest.fixture
def mock_display_manager():
"""Create a mock DisplayManager for testing."""
+14
View File
@@ -44,6 +44,20 @@ def dm(tmp_path_factory):
# this session; the individual tests that care still override it further.
manager._snapshot_path = str(
tmp_path_factory.mktemp("dirty_tracking") / "led_matrix_preview.png")
# _setup_matrix() swallows every construction failure and falls back to
# matrix=None, so a broken environment reaches the tests as fifteen
# identical "'NoneType' object has no attribute 'SwapOnVSync'" errors that
# name neither this fixture nor the real cause. Fail here instead, once,
# and say where to look.
if manager.matrix is None:
pytest.fail(
"DisplayManager fell back to matrix=None: RGBMatrix construction "
"raised (the 'Failed to initialize RGB Matrix' log line above "
"carries the reason). Known causes: the emulator adapter losing a "
"fixed TCP port to another process -- see pytest_configure in "
"test/conftest.py, which pins the port-free 'raw' adapter -- or a "
"patch('src.display_manager.RGBMatrix') leaked from an earlier "
"test module.")
yield manager
DisplayManager._instance = None
DisplayManager._initialized = False