fix(state): holder scan resolves symlinked homes; doctor's ro URI escapes reserved chars
Review findings on #110914 (@ehz0ah): the psutil leg compared watched abspath against the kernel-resolved path psutil reports, so a symlinked HERMES_HOME on macOS returned no holders and let the fallback probe mint replacement sidecars under a live writer. Both sides now realpath. doctor's `file:{path}?mode=ro` truncated at '?'/'#' in a home name; build the URI with as_uri().
This commit is contained in:
@@ -151,7 +151,8 @@ def _check_directory_structure(should_fix: bool, f: Finding) -> None:
|
||||
def _session_count(state_db_path: Path):
|
||||
import sqlite3
|
||||
# mode=ro: doctor is a reader; a writable open of a gateway-held WAL DB is the second-writer class (#103339).
|
||||
conn = sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True)
|
||||
# as_uri() percent-encodes '?' / '#' in the home path; a raw f-string URI truncates there.
|
||||
conn = sqlite3.connect(Path(state_db_path).resolve().as_uri() + "?mode=ro", uri=True)
|
||||
try:
|
||||
return conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
||||
finally:
|
||||
|
||||
@@ -188,7 +188,9 @@ def foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]:
|
||||
if _IS_WINDOWS:
|
||||
return []
|
||||
|
||||
db_path_str = os.path.abspath(os.fspath(db_path))
|
||||
# realpath, not abspath: psutil/libproc report the kernel-resolved pathname, so a symlinked
|
||||
# HERMES_HOME would otherwise make every holder invisible and let maintenance proceed.
|
||||
db_path_str = os.path.realpath(os.fspath(db_path))
|
||||
watched = {
|
||||
canonical_sqlite_path(db_path_str),
|
||||
canonical_sqlite_path(db_path_str + "-wal"),
|
||||
@@ -303,7 +305,7 @@ def foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]:
|
||||
continue
|
||||
for opened in info.get("open_files") or ():
|
||||
path = getattr(opened, "path", "")
|
||||
if path and canonical_sqlite_path(path) in watched:
|
||||
if path and canonical_sqlite_path(os.path.realpath(path)) in watched:
|
||||
holders.append((pid, path))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
|
||||
@@ -47,3 +47,20 @@ def test_doctor_checkpoint_runs_only_on_the_exclusive_repair_guard(tmp_path, mon
|
||||
assert finding.fixed == 1 and not finding.issues
|
||||
# Every writable open went through the repair connector (probe + exclusive guard); none was a bare connect.
|
||||
assert bare_connects and len(bare_connects) == len(guard_connects) >= 2
|
||||
|
||||
|
||||
def test_session_count_reads_a_home_with_uri_reserved_characters(tmp_path):
|
||||
"""`file:` URIs treat '?' and '#' as delimiters; a home named `profile?blue` must still count."""
|
||||
import sqlite3
|
||||
|
||||
from hermes_cli.doctor_state import _session_count
|
||||
|
||||
home = tmp_path / "profile?blue#x"
|
||||
home.mkdir()
|
||||
db = home / "state.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute("CREATE TABLE sessions(id TEXT)")
|
||||
conn.execute("INSERT INTO sessions VALUES ('a'), ('b')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert _session_count(db) == 2
|
||||
|
||||
@@ -56,3 +56,30 @@ def test_repair_refuses_delete_mode_db_held_open_by_another_process(delete_mode_
|
||||
holder.wait(timeout=10)
|
||||
assert report["repaired"] is False
|
||||
assert "stop the gateway" in (report["error"] or "").lower()
|
||||
|
||||
|
||||
def test_holder_scan_sees_through_a_symlinked_home(tmp_path):
|
||||
"""psutil/libproc report the resolved pathname; a holder opened via the real path must be found
|
||||
when the scan is asked about the alias, or maintenance proceeds under a live writer. The Linux
|
||||
/proc leg already compares inodes; the textual psutil leg (macOS) is the one this pins."""
|
||||
import sqlite3
|
||||
|
||||
from hermes_state_holders import foreign_state_db_holders
|
||||
|
||||
real = tmp_path / "real-home"
|
||||
real.mkdir()
|
||||
alias = tmp_path / "alias-home"
|
||||
alias.symlink_to(real, target_is_directory=True)
|
||||
db = real / "state.db"
|
||||
sqlite3.connect(db).execute("CREATE TABLE t(x)").connection.close()
|
||||
holder = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sqlite3, sys, time; c = sqlite3.connect({str(db)!r}); c.execute('BEGIN IMMEDIATE'); "
|
||||
"print('held', flush=True); time.sleep(30)"],
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
try:
|
||||
assert holder.stdout.readline().strip() == "held"
|
||||
assert any(pid == holder.pid for pid, _ in foreign_state_db_holders(alias / "state.db"))
|
||||
finally:
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
|
||||
Reference in New Issue
Block a user