diff --git a/hermes_cli/doctor_state.py b/hermes_cli/doctor_state.py index eef40064f3..0a038c64de 100644 --- a/hermes_cli/doctor_state.py +++ b/hermes_cli/doctor_state.py @@ -344,11 +344,32 @@ def _state_db_wal(f: Finding, should_fix: bool, state_db_path: Path) -> None: check_info(f"WAL file is {size // (1024*1024)} MB (normal for active sessions)") +def _retired_wal_holders(f: Finding, state_db_path: Path, _DHH: str) -> bool: + """Name the processes holding a retired -wal/-shm generation (#110054). Every SessionDB open is + refused while they live, and the current inode has no holders, so the plain holder count says + "0 holding the DB open" beside a green state.db line — the opposite of the truth.""" + from hermes_constants import profile_cli_selector + from hermes_state_dbfile import iter_deleted_sqlite_sidecar_holders + pids = list(dict.fromkeys(pid for pid, _ in iter_deleted_sqlite_sidecar_holders(state_db_path))) + if not pids: + return False + rendered = ", ".join(f"pid {pid}" for pid in pids) + check_warn(f"{_DHH}/state.db: {len(pids)} process(es) still hold a retired WAL generation ({rendered})", + "(every new session refuses to open until they exit; health/stats probes skipped)") + f.issues.append(f"state.db retired WAL generation held by {rendered} — stop them: " + f"'hermes {profile_cli_selector()}gateway stop', quit the Desktop app / dashboard, " + "then rerun 'hermes doctor'") + return True + + @doctor_check() def _check_state_db(should_fix: bool, f: Finding) -> None: """state.db session count, FTS write health, schema repair, stats snapshot, WAL size.""" from hermes_cli.doctor import HERMES_HOME, _DHH state_db_path = HERMES_HOME / "state.db" + # A read-only connect on the new generation is itself another opener, so nothing below may run. + if _retired_wal_holders(f, state_db_path, _DHH): + return if state_db_path.exists(): _state_db_health(f, should_fix, state_db_path, _DHH) _state_db_stats(f.issues, state_db_path) diff --git a/tests/hermes_cli/test_doctor_wal_holder_guard.py b/tests/hermes_cli/test_doctor_wal_holder_guard.py index f08e685861..a66f0b0d38 100644 --- a/tests/hermes_cli/test_doctor_wal_holder_guard.py +++ b/tests/hermes_cli/test_doctor_wal_holder_guard.py @@ -49,3 +49,29 @@ def test_wal_checkpoint_skipped_while_live_writer_holds_db(tmp_path): assert finding.fixed == 0 assert any("gateway" in issue for issue in finding.issues) + + +def test_doctor_names_retired_wal_holders_instead_of_healthy_state_db(tmp_path, monkeypatch, capsys): + """After the deleted-WAL guard fires (#110054), doctor must name the PIDs holding the retired + generation, must not print a healthy state.db line, and must not open the store itself (the + health probe is another opener) nor checkpoint under --fix.""" + import hermes_cli.doctor as doctor + import hermes_cli.doctor_state as doctor_state + import hermes_state_dbfile + + db = tmp_path / "state.db" + db.write_bytes(b"") + monkeypatch.setattr(doctor, "HERMES_HOME", tmp_path) + monkeypatch.setattr(hermes_state_dbfile, "iter_deleted_sqlite_sidecar_holders", + lambda path: [(4242, f"{path}-wal"), (4242, f"{path}-shm")]) + probed = [] + monkeypatch.setattr(doctor_state, "_state_db_health", lambda *a, **k: probed.append(a)) + monkeypatch.setattr(doctor_state, "_state_db_wal", lambda *a, **k: probed.append(a)) + + finding = doctor_state._check_state_db(True) + out = capsys.readouterr().out + + assert "4242" in out and "retired WAL" in out + assert "✓" not in out + assert probed == [] and finding.fixed == 0 + assert any("4242" in issue and "gateway stop" in issue for issue in finding.issues)