From 26837b56e0349de7f387aea5e9562855c58ec8fa Mon Sep 17 00:00:00 2001 From: AJ Banerjee Date: Sun, 3 May 2026 21:52:41 -0700 Subject: [PATCH] fix(gateway): detect stale sys.modules via expected exports (Issue #17648) - Add _loaded_modules_missing_expected_exports() for cfg_get / utils helpers - Run export check before mtime comparison so live-update mismatches restart - Log when mtime baseline is zero but boot time was captured - Extend stale-code self-check tests --- gateway/run.py | 23 ++++++++++++++ tests/gateway/test_stale_code_self_check.py | 33 +++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 7871686256873..1565dadb1a8f7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -133,6 +133,20 @@ def _compute_repo_mtime(repo_root: Path) -> float: return newest +def _loaded_modules_missing_expected_exports() -> bool: + """Return True if loaded ``hermes_cli.config`` or ``utils`` lack expected attributes.""" + cfg_mod = sys.modules.get("hermes_cli.config") + if cfg_mod is not None and not hasattr(cfg_mod, "cfg_get"): + return True + utils_mod = sys.modules.get("utils") + if utils_mod is not None: + if not hasattr(utils_mod, "atomic_replace"): + return True + if not hasattr(utils_mod, "base_url_host_matches"): + return True + return False + + def _coerce_gateway_timestamp(value: Any) -> Optional[float]: """Best-effort conversion of stored gateway timestamps to epoch seconds. @@ -1019,6 +1033,13 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._boot_repo_mtime: float = _compute_repo_mtime( self._repo_root_for_staleness, ) + if self._boot_repo_mtime <= 0.0 and self._boot_wall_time: + logger.warning( + "Stale-code mtime baseline unavailable (no readable sentinel " + "files under %s). Export-based detection still applies. " + "See Issue #17648.", + self._repo_root_for_staleness, + ) except Exception: self._boot_wall_time = 0.0 self._repo_root_for_staleness = Path(".") @@ -2637,6 +2658,8 @@ def _detect_stale_code(self) -> bool: sentinel file is readable, to avoid false-positive restart loops in unusual checkouts (sparse clones, read-only filesystems). """ + if _loaded_modules_missing_expected_exports(): + return True if not self._boot_wall_time or not self._boot_repo_mtime: return False try: diff --git a/tests/gateway/test_stale_code_self_check.py b/tests/gateway/test_stale_code_self_check.py index 5289f575d40f8..a20fca4e6cc17 100644 --- a/tests/gateway/test_stale_code_self_check.py +++ b/tests/gateway/test_stale_code_self_check.py @@ -9,15 +9,15 @@ """ import os +import sys import time +import types from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest from gateway.run import ( GatewayRunner, _compute_repo_mtime, + _loaded_modules_missing_expected_exports, _STALE_CODE_SENTINELS, ) @@ -96,6 +96,33 @@ def test_detect_stale_code_false_when_files_unchanged(tmp_path): assert runner._detect_stale_code() is False +def test_loaded_modules_missing_expected_exports_detects_stale_config(monkeypatch): + stale_cfg = types.ModuleType("hermes_cli.config") + monkeypatch.setitem(sys.modules, "hermes_cli.config", stale_cfg) + assert _loaded_modules_missing_expected_exports() is True + + +def test_loaded_modules_missing_expected_exports_detects_stale_utils(monkeypatch): + stale_utils = types.ModuleType("utils") + monkeypatch.setitem(sys.modules, "utils", stale_utils) + assert _loaded_modules_missing_expected_exports() is True + + +def test_detect_stale_code_true_when_sys_modules_config_lacks_cfg_get( + tmp_path, monkeypatch +): + stale_cfg = types.ModuleType("hermes_cli.config") + monkeypatch.setitem(sys.modules, "hermes_cli.config", stale_cfg) + + repo = _make_tmp_repo(tmp_path) + baseline = time.time() - 100 + for rel in _STALE_CODE_SENTINELS: + os.utime(repo / rel, (baseline, baseline)) + + runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline) + assert runner._detect_stale_code() is True + + def test_detect_stale_code_true_after_update(tmp_path): """Sentinel files newer than boot snapshot → stale.""" repo = _make_tmp_repo(tmp_path)