diff --git a/contributors/emails/luc@provencher.family b/contributors/emails/luc@provencher.family new file mode 100644 index 0000000000000..b3d1853787d7c --- /dev/null +++ b/contributors/emails/luc@provencher.family @@ -0,0 +1,2 @@ +Rroven +# PR #107701 test co-author diff --git a/hermes_cli/web_server_lifecycle.py b/hermes_cli/web_server_lifecycle.py index 14b554628cd0e..3664d934a6ca0 100644 --- a/hermes_cli/web_server_lifecycle.py +++ b/hermes_cli/web_server_lifecycle.py @@ -168,18 +168,17 @@ def _resolve_restart_drain_timeout() -> float: def _eager_reconcile_own_session_db() -> None: - """One writable open of this process's own state.db at startup. - - ``SessionDB.__init__`` runs ``_init_schema`` → ``_reconcile_columns`` with - open-time lock patience. Never raises: an unfixable store still gets the - per-poll read-probe heal in :func:`_open_session_db_at_path`. + """Heal a stale schema in this process's own state.db at startup — read-only, so the dashboard + never becomes a second writable owner beside the gateway (a writable open ran full schema init + plus a close-time checkpoint against its writer). Access-mode semantics: see + :func:`hermes_cli.web_server_sessions._open_session_db_at_path`. Never raises: an unfixable + store still gets the per-poll read-probe heal. """ try: - from hermes_state import _default_db_path - from hermes_state_registry import acquire, release_or_close + from hermes_cli.web_server_sessions import _open_session_db_for_profile + from hermes_state_registry import release_or_close - db = acquire(Path(_default_db_path())) - release_or_close(db) + release_or_close(_open_session_db_for_profile(None, read_only=True)) except Exception as exc: _log.warning( "startup schema reconcile of state.db failed (%s); session " diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 72242e27b154d..27e056250f523 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -581,6 +581,33 @@ def boom(*args, **kwargs): # Must swallow — reads fall back to the per-poll probe heal. _web_server_lifecycle._eager_reconcile_own_session_db() + def test_startup_eager_reconcile_opens_read_only(self, monkeypatch): + """A healthy store gets a read-only open (no second writable owner) and the handle is + released. The stale-schema heal is covered by test_startup_eager_reconcile_heals_stale_store.""" + from pathlib import Path + + import hermes_state + import hermes_cli.web_server_sessions as _web_server_sessions + + calls = [] + closed = [] + + def fake_open(db_path: Path, *, read_only: bool): + calls.append((str(db_path), read_only)) + return SimpleNamespace(close=lambda: closed.append(True)) + + monkeypatch.setattr(hermes_state, "_default_db_path", lambda: "/tmp/fake-state.db") + monkeypatch.setattr( + _web_server_sessions, "_open_session_db_at_path", fake_open, + ) + + _web_server_lifecycle._eager_reconcile_own_session_db() + + assert calls == [("/tmp/fake-state.db", True)], ( + "eager reconcile must open state.db read-only on a healthy store" + ) + assert closed == [True], "the startup handle must be released, not leaked" + def test_heal_gives_up_when_reconcile_cannot_fix_the_store(self, monkeypatch): """A probe failure reconciliation can't cure must not retry forever.