Files
hermes-webui/tests/test_background_process_restart_recovery.py
T
allenliang2022 ce048b0a5b fix(wakeup): recover terminal process completions after restart (#6287)
Recover checkpointed core background processes and rebuild PROCESS_SESSION_INDEX on WebUI startup, so an ordinary terminal(background=True, notify_on_complete=True) proc_* completion that outlives a WebUI restart can still wake its original session. Complements #6283 (which covered async_delegation completions). Thanks @allenliang2022.
2026-07-18 20:07:54 -07:00

161 lines
4.8 KiB
Python

"""Regression coverage for notify_on_complete across a WebUI restart."""
import threading
import time
from types import SimpleNamespace
import pytest
from api import background_process as bp
class _FakeThread:
def __init__(self, *args, **kwargs):
self.started = False
def is_alive(self):
return self.started
def start(self):
self.started = True
class _FakeProcessSession:
def __init__(self, session_key):
self.session_key = session_key
def test_start_drain_thread_invokes_recovery(monkeypatch):
calls = []
monkeypatch.setattr(bp, "_DRAIN_THREAD", None)
monkeypatch.setattr(bp, "recover_processes_for_webui", lambda: calls.append("recover") or 0)
monkeypatch.setattr(bp.threading, "Thread", _FakeThread)
assert bp.start_drain_thread() is True
assert calls == ["recover"]
def test_start_drain_thread_survives_recovery_failure(monkeypatch):
def fail_recovery():
raise OSError("corrupt checkpoint")
monkeypatch.setattr(bp, "_DRAIN_THREAD", None)
monkeypatch.setattr(bp, "recover_processes_for_webui", fail_recovery)
monkeypatch.setattr(bp.threading, "Thread", _FakeThread)
assert bp.start_drain_thread() is True
assert bp._DRAIN_THREAD is not None
assert bp._DRAIN_THREAD.is_alive()
def test_recovery_runs_once_and_rebuilds_session_mapping(monkeypatch):
calls = {"recover": 0, "registered": []}
class FakeRegistry:
def recover_from_checkpoint(self):
calls["recover"] += 1
return 1
def list_sessions(self):
return [{
"session_id": "proc_recovered",
"detached": True,
}]
def get(self, process_id):
assert process_id == "proc_recovered"
return _FakeProcessSession("webui-session")
fake_registry = FakeRegistry()
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
monkeypatch.setattr(
bp,
"register_process_session",
lambda key, sid: calls["registered"].append((key, sid)),
)
def get_session(sid, metadata_only=False):
return SimpleNamespace(id=sid)
assert bp.recover_processes_for_webui(fake_registry, get_session) == 1
assert bp.recover_processes_for_webui(fake_registry, get_session) == 0
assert calls == {
"recover": 1,
"registered": [("webui-session", "webui-session")],
}
def test_partial_recovery_retry_does_not_repeat_checkpoint_adoption(monkeypatch):
calls = {"recover": 0, "list": 0}
class FlakyRegistry:
def recover_from_checkpoint(self):
calls["recover"] += 1
return 1
def list_sessions(self):
calls["list"] += 1
if calls["list"] == 1:
raise OSError("transient list failure")
return []
registry = FlakyRegistry()
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
with pytest.raises(OSError, match="transient list failure"):
bp.recover_processes_for_webui(registry, lambda *_args, **_kwargs: None)
assert bp.recover_processes_for_webui(registry, lambda *_args, **_kwargs: None) == 0
assert calls == {"recover": 1, "list": 2}
def test_concurrent_direct_recovery_runs_once(monkeypatch):
calls = {"recover": 0}
class FakeRegistry:
def recover_from_checkpoint(self):
time.sleep(0.02)
calls["recover"] += 1
return 1
def list_sessions(self):
return []
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
registry = FakeRegistry()
barrier = threading.Barrier(8)
results = []
def recover():
barrier.wait()
results.append(
bp.recover_processes_for_webui(registry, lambda *_args, **_kwargs: None)
)
workers = [threading.Thread(target=recover) for _ in range(8)]
for worker in workers:
worker.start()
for worker in workers:
worker.join(timeout=5)
assert calls["recover"] == 1
assert sorted(results) == [0] * 7 + [1]
def test_recovery_is_fail_soft_without_agent(monkeypatch):
real_import = __import__
def fake_import(name, *args, **kwargs):
if name == "tools.process_registry":
raise ImportError("Hermes Agent not installed")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
monkeypatch.setattr("builtins.__import__", fake_import)
assert bp.recover_processes_for_webui() == 0
assert bp._PROCESS_RECOVERY_DONE is False