Files
hermes-webui/tests/test_issue5331_index_only_ghost_cleanup.py
T
nanw 161e4173c2 fix(sessions): prune index-only ghost sessions (#5331)
Phase 2 of cleanup now sweeps _index.json for stale entries with no
backing file and no in-memory session, removing them regardless of
title (catches multi-language 'Untitled' variants). Phase 3 only
deletes the index when Phase 1 removed files AND Phase 2 couldn't
run, fixing the cache-busting on every cleanup call.

Fix two phase-interaction bugs flagged by Greptile review:
- Track phase1_removed_ids to prevent Phase 2 double-counting
  sessions already removed from disk by Phase 1.
- Track phase2_rewrote_index so Phase 3 skips deletion when
  Phase 2 already cleaned the index in-place.

Adds test_issue5331_index_only_ghost_cleanup.py with 12 test cases.
2026-07-01 17:41:31 +08:00

420 lines
15 KiB
Python

"""Regression coverage for index-only ghost session cleanup (#5331).
#5331: sessions created in ``_index.json`` without a backing ``.json`` sidecar
file accumulate in the sidebar and cannot be removed via normal UI. The existing
``_handle_sessions_cleanup`` only iterates ``SESSION_DIR.glob('*.json')`` —
structurally blind to index-only entries.
Phase 2 (new) reads the index and prunes entries that have no backing file
and no in-memory session. A legitimate session always has either a file on
disk or an in-memory ``SESSIONS`` entry (or both).
"""
from __future__ import annotations
import io
import json
import sys
import threading
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parent.parent
if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))
@pytest.fixture
def mock_env(tmp_path, monkeypatch):
"""Set up an isolated SESSION_DIR with monkeypatched globals.
Patches at the ``api.config`` level so that ``api.routes`` AND
``api.models`` (used by ``Session.load`` in Phase 1) both read
the test temp paths.
"""
import api.config as config_mod
import api.routes as routes
import api.models as models
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
index_file = sessions_dir / "_index.json"
# Patch at the config source so all importers see the same test paths.
monkeypatch.setattr(config_mod, "SESSION_DIR", sessions_dir)
monkeypatch.setattr(config_mod, "SESSION_INDEX_FILE", index_file)
monkeypatch.setattr(config_mod, "SESSIONS", {})
monkeypatch.setattr(routes, "LOCK", threading.Lock())
# Also refresh the module-level aliases in routes and models so they
# pick up the patched config values.
monkeypatch.setattr(routes, "SESSION_DIR", sessions_dir)
monkeypatch.setattr(routes, "SESSION_INDEX_FILE", index_file)
monkeypatch.setattr(routes, "SESSIONS", {})
monkeypatch.setattr(models, "SESSION_DIR", sessions_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
return sessions_dir, index_file
def _fake_handler():
"""Minimal handler mock for ``_handle_sessions_cleanup``.
``j()`` writes the JSON response to ``handler.wfile`` (a ``BytesIO``),
so callers read the result via ``handler.wfile.getvalue()``.
"""
handler = type("FakeHandler", (), {})()
handler.wfile = io.BytesIO()
handler.send_response = lambda status: None
handler.send_header = lambda key, value: None
handler.end_headers = lambda: None
return handler
def _fake_handler_result(handler):
"""Parse the JSON written to ``handler.wfile`` by ``j()``."""
return json.loads(handler.wfile.getvalue())
def _make_session_file(sessions_dir, sid, title="Untitled", **extra):
"""Write a ``.json`` session file and return the compact entry dict.
Uses the minimal Session-serializable format: ``session_id`` is required,
all other fields have defaults. ``**extra`` is folded directly into
the JSON so callers can set ``message_count``, ``created_at``, etc.
"""
entry = {"session_id": sid, "title": title, **extra}
(sessions_dir / f"{sid}.json").write_text(json.dumps(entry))
return entry
def _write_index(index_file, entries):
"""Write the session index file."""
index_file.write_text(json.dumps(entries, indent=2))
def _read_index(index_file):
"""Read the session index file, returning empty list on missing/corrupt."""
if not index_file.exists():
return []
try:
return json.loads(index_file.read_text(encoding="utf-8"))
except Exception:
return []
# ── Phase 2: index-only ghost sweep (#5331) ────────────────────────────────
def test_cleanup_prunes_index_only_ghosts(mock_env):
"""Index-only Untitled entries with no backing file are pruned."""
sessions_dir, index_file = mock_env
# Legitimate sessions with backing files — should survive.
_make_session_file(sessions_dir, "sess-a", "Legit")
_make_session_file(sessions_dir, "sess-b", "Production")
# Ghost entry: exists only in index, no backing file.
_write_index(index_file, [
{"session_id": "sess-a", "title": "Legit", "message_count": 5},
{"session_id": "sess-b", "title": "Production", "message_count": 42},
{"session_id": "sess-ghost-1", "title": "Untitled", "message_count": 3},
])
import api.routes as routes
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
assert result["ok"] is True
assert result["cleaned"] == 1 # only the ghost
updated = _read_index(index_file)
sids = {e["session_id"] for e in updated}
assert "sess-ghost-1" not in sids
assert "sess-a" in sids
assert "sess-b" in sids
def test_cleanup_keeps_in_memory_ghosts(mock_env):
"""Index-only entries still in memory (SESSIONS) are kept."""
sessions_dir, index_file = mock_env
import api.routes as routes
# One entry in-memory but no backing file — should survive Phase 2.
routes.SESSIONS["sess-mem"] = "placeholder"
_write_index(index_file, [
{"session_id": "sess-mem", "title": "Untitled", "message_count": 2},
])
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
assert result["cleaned"] == 0
updated = _read_index(index_file)
assert any(e["session_id"] == "sess-mem" for e in updated)
def test_cleanup_mixed_ghosts_and_legit(mock_env):
"""Mix of ghost, file-backed, and in-memory entries — only ghosts pruned."""
sessions_dir, index_file = mock_env
import api.routes as routes
# File-backed (survive Phase 1 — not Untitled, or has messages)
_make_session_file(sessions_dir, "sess-1", "Chat")
_make_session_file(sessions_dir, "sess-2", "Chat2")
# In-memory (survive)
routes.SESSIONS["sess-3"] = "placeholder"
# Ghosts (pruned)
ghost_1 = {"session_id": "sess-ghost-a", "title": "Untitled", "message_count": 5}
ghost_2 = {"session_id": "sess-ghost-b", "title": "Something", "message_count": 0}
_write_index(index_file, [
{"session_id": "sess-1", "title": "Chat", "message_count": 10},
{"session_id": "sess-2", "title": "Untitled", "message_count": 1},
{"session_id": "sess-3", "title": "Untitled", "message_count": 2},
ghost_1,
ghost_2,
])
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
assert result["cleaned"] == 2
updated = _read_index(index_file)
sids = {e["session_id"] for e in updated}
assert "sess-ghost-a" not in sids
assert "sess-ghost-b" not in sids
assert "sess-1" in sids
assert "sess-2" in sids
assert "sess-3" in sids
def test_cleanup_empty_index_does_not_crash(mock_env):
"""An index with no entries leaves Phase 2 a no-op."""
sessions_dir, index_file = mock_env
_write_index(index_file, [])
import api.routes as routes
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
assert result["cleaned"] == 0
def test_cleanup_no_index_file_does_not_crash(mock_env):
"""No index file at all — Phase 2 is skipped, no error."""
sessions_dir, index_file = mock_env
assert not index_file.exists()
import api.routes as routes
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
assert result["cleaned"] == 0
def test_cleanup_corrupt_index_does_not_crash(mock_env):
"""A corrupt index file is caught by the try/except in Phase 2."""
sessions_dir, index_file = mock_env
index_file.write_text("not valid json")
import api.routes as routes
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
assert result["cleaned"] == 0 # Phase 2 gracefully skipped
# Index file still exists and still invalid.
assert index_file.exists()
assert index_file.read_text() == "not valid json"
def test_cleanup_ghost_without_session_id_field(mock_env):
"""An index entry missing ``session_id`` is skipped (not treated as ghost)."""
sessions_dir, index_file = mock_env
_make_session_file(sessions_dir, "sess-real", "Real")
_write_index(index_file, [
{"session_id": "sess-real", "title": "Real", "message_count": 1},
{"title": "NoId", "message_count": 3}, # missing session_id
])
import api.routes as routes
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
assert result["cleaned"] == 0
updated = _read_index(index_file)
assert len(updated) == 2 # both kept
def test_cleanup_index_only_empty_ghosts_without_explicit_messages(mock_env):
"""Index-only ghost with no ``messages`` field is still pruned.
The minimal index entry ``{session_id, title}`` works — Phase 2 does not
try to load the session (there's no file), so it just checks sid presence
in ``live_ids`` and ``in_memory_ids``.
"""
sessions_dir, index_file = mock_env
_make_session_file(sessions_dir, "sess-real", "Real")
_write_index(index_file, [
{"session_id": "sess-real", "title": "Real", "message_count": 1},
{"session_id": "sess-ghost-minimal"},
])
import api.routes as routes
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
assert result["cleaned"] == 1
updated = _read_index(index_file)
assert len(updated) == 1
assert updated[0]["session_id"] == "sess-real"
# ── Phase 3: index cache thrash fix ──────────────────────────────────────────
def test_cleanup_deletes_index_only_when_phase1_touched(mock_env):
"""Phase 3 only unlinks _index.json when Phase 1 removed files.
Phase 2 always writes the index in-place. The post-cleanup
``unlink(missing_ok=True)`` should only fire when Phase 1 removed
actual session files, because that's the only case where the index
was NOT already written in-place by Phase 2.
"""
sessions_dir, index_file = mock_env
import api.routes as routes
# Only ghosts in index — Phase 1 touches nothing, Phase 2 writes in-place.
_write_index(index_file, [
{"session_id": "sess-ghost", "title": "Untitled", "message_count": 1},
])
routes._handle_sessions_cleanup(_fake_handler(), {})
# After Phase-2-write, index should exist (Phase 3 should not have unlinked it).
assert index_file.exists()
# Verify it was cleaned too
updated = _read_index(index_file)
assert len(updated) == 0
def test_cleanup_index_rewritten_when_phase1_removed_files(mock_env):
"""Phase 2 rewrites the index when Phase 1 removes files.
Phase 2 handles the stale index entries left by Phase 1 removals,
so Phase 3 does NOT delete the index — it's already clean.
"""
sessions_dir, index_file = mock_env
import api.routes as routes
# A file-backed Untitled-0-message session — Phase 1 target.
_make_session_file(sessions_dir, "sess-orphan", "Untitled")
_write_index(index_file, [
{"session_id": "sess-orphan", "title": "Untitled", "message_count": 0},
])
routes._handle_sessions_cleanup(_fake_handler(), {}, zero_only=False)
# Phase 1 unlinked the file. Phase 2 removed the stale index entry.
# Phase 3 skipped because Phase 2 already cleaned the index.
# Index exists with zero entries (clean).
assert index_file.exists()
assert _read_index(index_file) == []
def test_cleanup_phase3_deletes_when_phase2_could_not_run(mock_env):
"""Phase 3 still deletes the index when Phase 2 couldn't run.
When the index is corrupt (or missing), Phase 2 gracefully skips.
Phase 1 has removed files, leaving no way to clean the stale index,
so Phase 3 deletes it for a fresh rebuild.
"""
sessions_dir, index_file = mock_env
import api.routes as routes
# Phase 1 target: file-backed session with zero messages.
_make_session_file(sessions_dir, "sess-orphan", "Untitled")
# Corrupt index — Phase 2 will catch the exception and skip.
index_file.write_text("corrupt json")
routes._handle_sessions_cleanup(_fake_handler(), {}, zero_only=False)
# Phase 1 removed the file. Phase 2 caught the corrupt-json exception.
# Phase 3: phase1_touched=True, phase2_rewrote_index=False → unlink.
assert not index_file.exists()
def test_cleanup_no_double_count_when_phase1_and_phase2_overlap(mock_env):
"""Phase 2 does not double-count sessions already removed by Phase 1.
When Phase 1 removes a file-backed session, the corresponding index
entry becomes stale. Phase 2 sees it (no file, not in memory) but
recognises it as a Phase-1 removal via ``phase1_removed_ids`` and
skips counting it — preventing ``cleaned`` from inflating.
"""
sessions_dir, index_file = mock_env
import api.routes as routes
# Phase 1 target: file-backed session with zero messages.
_make_session_file(sessions_dir, "sess-zero", "Untitled")
# Phase 2 target: index-only ghost.
_write_index(index_file, [
{"session_id": "sess-zero", "title": "Untitled", "message_count": 0},
{"session_id": "sess-ghost", "title": "Untitled", "message_count": 1},
])
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {})
result = _fake_handler_result(handler)
# Phase 1: 1 (sess-zero). Phase 2: 1 (sess-ghost). Total = 2.
assert result["cleaned"] == 2
# Index exists (Phase 2 rewrote it, Phase 3 skipped).
assert index_file.exists()
assert _read_index(index_file) == []
# ── zero_only mode preserves Phase 2 ─────────────────────────────────────────
def test_cleanup_zero_only_still_runs_phase2(mock_env):
"""Phase 2 (ghost sweep) runs regardless of ``zero_only`` flag.
Phase 2 handles the stale index entry from Phase 1's removal, so the
index survives in a clean state (empty) and no double-counting occurs.
"""
sessions_dir, index_file = mock_env
# Phase-1 target: a real file with zero messages
_make_session_file(sessions_dir, "sess-zero", "SomeTitle")
# Ghost in index only
_write_index(index_file, [
{"session_id": "sess-zero", "title": "SomeTitle", "message_count": 0},
{"session_id": "sess-ghost", "title": "Untitled", "message_count": 1},
])
import api.routes as routes
handler = _fake_handler()
routes._handle_sessions_cleanup(handler, {}, zero_only=True)
result = _fake_handler_result(handler)
# Phase 1: 1 (sess-zero, zero messages). Phase 2: 1 (sess-ghost). Total = 2.
assert result["ok"] is True
assert result["cleaned"] == 2
# Index exists (Phase 2 rewrote it, Phase 3 skipped).
assert index_file.exists()
assert _read_index(index_file) == []