diff --git a/src/plugin_system/compatibility.py b/src/plugin_system/compatibility.py index 9b676cd2..4537be85 100644 --- a/src/plugin_system/compatibility.py +++ b/src/plugin_system/compatibility.py @@ -28,6 +28,7 @@ from __future__ import annotations import re +from pathlib import Path from typing import Any, Dict, Optional, Tuple # Below this, the core's self-reported version is not evidence of anything. @@ -35,6 +36,45 @@ TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0) +# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/ +_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py" +_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M) + + +def current_core_version() -> str: + """The core version as it is on disk right now, not as it was at import. + + ``from src import __version__`` binds whatever the process loaded at start. + The web UI runs as its own long-lived service (``ledmatrix-web.service``), + and updating the core replaces files on disk without restarting it -- the + update route says so explicitly and asks the user to restart. Its prompt + names the *display* service, so a user who follows it leaves the web + process holding the old number. + + The plugin store's gate lives in that web process. Stale by one release is + exactly the case that matters: every plugin flooring on the release you + just installed gets refused, with a message blaming a core version that is + already correct on disk. 3.3.0 is the first release where that hits a whole + plugin family at once -- all eight sports scoreboards floor there. + + Reading the file costs one stat and a small read per call, and only on the + install/update path. Any failure falls back to the imported value, so this + can only ever be as wrong as before, never worse. + """ + try: + text = _VERSION_FILE.read_text(encoding="utf-8") + match = _VERSION_RE.search(text) + if match: + return match.group(1) + except (OSError, UnicodeDecodeError): + pass + try: + from src import __version__ as imported + return imported + except Exception: # noqa: BLE001 - never let this raise + return "0.0.0" + + def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]: """Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable 3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated.""" diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index d8ae0e3d..7a4969be 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -772,8 +772,8 @@ def _warn_if_incompatible(self, plugin_id: str, manifest: Dict[str, Any]) -> Non newer than the running core. Advisory only — never raises — so a plugin that guards optional features with try/except keeps working. """ - from src import __version__ as core_version from src.plugin_system import compatibility + core_version = compatibility.current_core_version() compatible, _reason = compatibility.check(manifest, core_version) if compatible: diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 80a454fc..122cdf9c 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1467,8 +1467,11 @@ def _install_plugin_impl(self, plugin_id: str, branch: Optional[str] = None) -> # already had. Allowing it costs them a plugin that raises # ModuleNotFoundError at load and is reported only as one line # in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6). - from src import __version__ as core_version from src.plugin_system import compatibility + # On disk, not as imported: this process may predate the core + # update that made the plugin compatible. See + # compatibility.current_core_version. + core_version = compatibility.current_core_version() compatible, reason = compatibility.check(manifest, core_version) if not compatible: @@ -1612,8 +1615,8 @@ def install_from_url(self, repo_url: str, plugin_id: str = None, plugin_path: st # # Before the move, so the `finally` below removes the temp tree and # nothing half-installed is left behind. - from src import __version__ as core_version from src.plugin_system import compatibility + core_version = compatibility.current_core_version() compatible, reason = compatibility.check(manifest, core_version) if not compatible: @@ -2644,8 +2647,8 @@ def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path, manifest_path, plugin_id, e) return True - from src import __version__ as core_version from src.plugin_system import compatibility + core_version = compatibility.current_core_version() compatible, reason = compatibility.check(manifest, core_version) if compatible: diff --git a/test/test_core_version_freshness.py b/test/test_core_version_freshness.py new file mode 100644 index 00000000..55cdaef9 --- /dev/null +++ b/test/test_core_version_freshness.py @@ -0,0 +1,122 @@ +"""The gate must read the core version from disk, not from its own import. + +`from src import __version__` binds whatever the process loaded at start. The +web UI is a long-lived service of its own (ledmatrix-web.service), and updating +the core replaces files on disk without restarting it -- the update route says +so and asks the user to restart, but its prompt named only the *display* +service, so a user who followed it left the web process holding the old number. + +The plugin store's gate lives in that web process, so being stale by exactly +one release is the case that bites: every plugin flooring on the release you +just installed is refused, with a message blaming a core version that is +already correct on disk. + +Observed on hardware: after updating a rig to 3.3.0 and restarting only the +display service, all eight sports scoreboards were refused with +"supports LEDMatrix >=3.3.0, but this system is running 3.2.0" while +src/__init__.py on that machine read 3.3.0. +""" + +import importlib +import sys + +import pytest + +from src.plugin_system import compatibility + + +class TestCurrentCoreVersion: + def test_it_reads_the_file_rather_than_the_imported_value(self, monkeypatch, tmp_path): + # Simulate a process whose import predates the update: the module + # object says 3.2.0 while the file on disk says 3.3.0. + import src + monkeypatch.setattr(src, "__version__", "3.2.0") + fake = tmp_path / "__init__.py" + fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + assert compatibility.current_core_version() == "3.3.0" + + def test_it_matches_the_real_file_by_default(self): + import src + assert compatibility.current_core_version() == src.__version__ + + @pytest.mark.parametrize("body", [ + "__version__ = '3.4.1'\n", + '__version__="3.4.1"\n', + '"""doc"""\n\n__version__ = "3.4.1" # trailing comment\n', + ]) + def test_it_tolerates_the_ways_that_line_gets_written(self, monkeypatch, tmp_path, body): + fake = tmp_path / "__init__.py" + fake.write_text(body, encoding="utf-8") + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + assert compatibility.current_core_version() == "3.4.1" + + def test_a_missing_file_falls_back_to_the_import(self, monkeypatch, tmp_path): + # Never worse than before: an unreadable file returns what the old + # code would have returned. + import src + monkeypatch.setattr(src, "__version__", "3.2.0") + monkeypatch.setattr(compatibility, "_VERSION_FILE", tmp_path / "gone.py") + assert compatibility.current_core_version() == "3.2.0" + + def test_a_file_without_the_line_falls_back(self, monkeypatch, tmp_path): + import src + monkeypatch.setattr(src, "__version__", "3.2.0") + fake = tmp_path / "__init__.py" + fake.write_text("# no version here\n", encoding="utf-8") + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + assert compatibility.current_core_version() == "3.2.0" + + def test_it_never_raises(self, monkeypatch, tmp_path): + # This runs on the install path; an exception here would surface as a + # failed update rather than a version mismatch. + bad = tmp_path / "__init__.py" + bad.write_bytes(b"\xff\xfe\x00 not utf-8 \xff") + monkeypatch.setattr(compatibility, "_VERSION_FILE", bad) + assert isinstance(compatibility.current_core_version(), str) + + +class TestTheBugItFixes: + def test_a_stale_import_no_longer_refuses_a_compatible_plugin(self, monkeypatch, tmp_path): + """The exact hardware failure, as a test.""" + import src + monkeypatch.setattr(src, "__version__", "3.2.0") # what the process holds + fake = tmp_path / "__init__.py" + fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") # what is on disk + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + + manifest = {"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.3.0"} + + stale_ok, _ = compatibility.check(manifest, src.__version__) + assert stale_ok is False, "precondition: the stale value is what refused it" + + fresh_ok, reason = compatibility.check( + manifest, compatibility.current_core_version()) + assert fresh_ok is True, f"the disk version must allow it, got: {reason}" + + def test_it_still_refuses_when_the_core_really_is_too_old(self, monkeypatch, tmp_path): + # The gate must not become permissive: a genuinely old core still says no. + fake = tmp_path / "__init__.py" + fake.write_text('__version__ = "3.2.0"\n', encoding="utf-8") + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + ok, reason = compatibility.check( + {"name": "Hockey", "min_ledmatrix_version": "3.3.0"}, + compatibility.current_core_version()) + assert ok is False + assert "3.2.0" in (reason or "") + + +class TestCallSites: + @pytest.mark.parametrize("module", [ + "src.plugin_system.store_manager", + "src.plugin_system.plugin_loader", + ]) + def test_no_gate_binds_the_version_at_import(self, module): + """Catch a future call site reintroducing the stale read.""" + import inspect + mod = importlib.import_module(module) + source = inspect.getsource(mod) + assert "from src import __version__ as core_version" not in source, ( + f"{module} binds __version__ at import; use " + f"compatibility.current_core_version() so a long-lived process " + f"sees a core update.") diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index e739dd8c..053724b4 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -1164,9 +1164,16 @@