Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,18 @@ async def _lifespan(app: "FastAPI"):
# Bring state.db schema current BEFORE the first session-list poll
# (#79531/#80037): a store left behind by `hermes update` otherwise 500s
# every poll while the read-probe heal loses to sibling lock contention.
# Daemon thread so a locked store never delays the socket (Desktop
# ready-probe times out at 10s, GH-73083).
threading.Thread(
# Off-thread so a locked store never delays the socket (Desktop
# ready-probe times out at 10s, GH-73083). NOT a daemon, and joined at
# shutdown: its sqlite connection must be closed by the thread that is
# stepping it. A daemon copy that outlived the lifespan had its
# connection closed from the main thread mid-probe (pytest's leaked-DB
# sweep) and segfaulted the interpreter. The worker is time-bounded by
# SessionDB's lock patience, so the join cannot hang shutdown.
eager_reconcile_thread = threading.Thread(
target=_eager_reconcile_own_session_db,
daemon=True,
name="statedb-eager-reconcile",
).start()
)
eager_reconcile_thread.start()

# Import hermes_cli.gateway *before* the yield: on Windows + 3.11 the
# import holds the GIL, so run_in_executor still froze the loop 15-22s and
Expand Down Expand Up @@ -274,6 +279,7 @@ def _boot_local_runtime():
pass
if os.getenv("HERMES_DESKTOP") == "1":
_terminate_desktop_managed_gateway()
eager_reconcile_thread.join()


def _app_state_default(app: "FastAPI", name: str, factory):
Expand Down
28 changes: 28 additions & 0 deletions tests/hermes_cli/test_web_server_boot_handshake.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,34 @@ def blocked_failure():
release.set()


def test_lifespan_shutdown_joins_statedb_reconcile_worker(monkeypatch):
"""The eager state.db reconcile runs off the startup path but never outlives
the lifespan: shutdown joins it, so its sqlite connection is only ever closed
by the thread stepping it (a daemon copy left running had its connection
closed cross-thread by teardown and segfaulted the interpreter)."""
from fastapi.testclient import TestClient

started = threading.Event()
finished = threading.Event()

def slow_reconcile():
started.set()
time.sleep(SLOW_SECONDS)
finished.set()

monkeypatch.setattr(web_server_mod, "_warm_gateway_module", lambda: None)
monkeypatch.setattr(web_server_mod, "_eager_reconcile_own_session_db", slow_reconcile)

before = time.perf_counter()
with TestClient(web_server_mod.app, raise_server_exceptions=False):
assert started.wait(timeout=1.0)
# Off the startup path: the socket is up long before the worker is done.
assert time.perf_counter() - before < SLOW_SECONDS * 0.8

assert finished.is_set(), "lifespan shutdown returned before the reconcile worker finished"
assert not any(t.name == "statedb-eager-reconcile" for t in threading.enumerate())


# ---------------------------------------------------------------------------
# Test 2 — get_status run_in_executor keeps event loop free for other requests
# ---------------------------------------------------------------------------
Expand Down
Loading