from __future__ import annotations import json import threading from urllib.parse import urlparse import pytest class _FakeHandler: def __init__(self): self.status = None self.sent_headers: list[tuple[str, str]] = [] self.body = bytearray() self.wfile = self self.headers = {} def send_response(self, code): self.status = code def send_header(self, key, value): self.sent_headers.append((key, value)) def end_headers(self): pass def write(self, data): self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8")) def get_json(self): return json.loads(self.body.decode("utf-8")) @pytest.fixture(autouse=True) def _disable_auth_for_profile_switch(monkeypatch): """Keep these watcher/profile-switch tests hermetic w.r.t. leaked auth state. The /api/profile/switch handler signs the active-profile cookie when auth is enabled (helpers.build_profile_cookie -> auth.sign_profile_cookie_value), which raises -> RuntimeError -> HTTP 409 if there's no active session. A sibling test that enables password/passkey auth in the shared in-process state would otherwise make the switch tests here return 409 instead of 200 under a full-suite run (they pass in isolation). Forcing auth OFF for this module makes them order-independent — they exercise watcher restart, not auth. Patch every import site so the value is consistent. """ for _mod in ("api.auth", "api.helpers", "api.routes"): try: monkeypatch.setattr(f"{_mod}.is_auth_enabled", lambda: False, raising=False) except Exception: pass yield def test_gateway_watcher_pins_explicit_profile_home(tmp_path, monkeypatch): from api import gateway_watcher as gw profile_a = tmp_path / "profile-a" profile_b = tmp_path / "profile-b" profile_a.mkdir() profile_b.mkdir() (profile_a / "state.db").touch() seen = [] def fake_rows(db_path, **kwargs): seen.append(db_path) return [ { "id": "session-a", "title": "Profile A", "model": None, "message_count": 1, "actual_message_count": 1, "started_at": 1, "last_activity": 2, "source": "telegram", "raw_source": "telegram", "session_source": "gateway", "source_label": "Telegram", } ] monkeypatch.setattr(gw, "read_importable_agent_session_rows", fake_rows) monkeypatch.setattr( gw, "_get_state_db_path", lambda *args: args[0].resolve() / "state.db" if args and args[0] is not None else profile_b / "state.db", ) watcher = gw.GatewayWatcher(hermes_home=profile_a, profile_name="a") sessions = gw._get_agent_sessions_from_db(watcher._state_db_path) assert watcher.profile_name == "a" assert watcher._state_db_path == profile_a.resolve() / "state.db" assert seen == [profile_a.resolve() / "state.db"] assert sessions[0]["session_id"] == "session-a" def test_restart_watcher_for_profile_replaces_singleton_with_profile_home(tmp_path, monkeypatch): from api import gateway_watcher as gw from api import profiles created = [] class FakeWatcher: def __init__(self, *, profile_name="", hermes_home=None, state_db_path=None): self.profile_name = profile_name self.hermes_home = hermes_home self.state_db_path = state_db_path self.started = False self.stopped = False created.append(self) def start(self): self.started = True def is_alive(self): return self.started def stop(self): self.stopped = True old_home = (tmp_path / "old").resolve() old = FakeWatcher(profile_name="old", hermes_home=old_home) monkeypatch.setattr(gw, "_watchers", {str(old_home): old}) monkeypatch.setattr(gw, "GatewayWatcher", FakeWatcher) monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda name: tmp_path / name) monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "target") watcher = gw.restart_watcher_for_profile("target") assert old.stopped is True assert watcher is created[-1] assert watcher.profile_name == "target" assert watcher.hermes_home == tmp_path / "target" assert watcher.started is True assert gw.get_watcher() is watcher def test_restart_watcher_for_profile_keeps_subscribed_other_profile(tmp_path, monkeypatch): from api import gateway_watcher as gw from api import profiles default_home = (tmp_path / "default").resolve() work_home = (tmp_path / "work").resolve() created = [] class FakeWatcher: def __init__(self, *, profile_name="", hermes_home=None, state_db_path=None): self.profile_name = profile_name self.hermes_home = hermes_home self.state_db_path = state_db_path self.started = False self.stopped = False self._subscribers = [] self._sub_lock = threading.Lock() created.append(self) def start(self): self.started = True def is_alive(self): return self.started def stop(self): self.stopped = True self.started = False default_watcher = FakeWatcher(profile_name="default", hermes_home=default_home) default_watcher.started = True default_watcher._subscribers.append(object()) monkeypatch.setattr(gw, "_watchers", {str(default_home): default_watcher}) monkeypatch.setattr(gw, "GatewayWatcher", FakeWatcher) monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda name: work_home) watcher = gw.restart_watcher_for_profile("work") assert default_watcher.stopped is False assert gw._watchers[str(default_home)] is default_watcher assert gw._watchers[str(work_home)] is watcher assert watcher.profile_name == "work" def test_restart_watcher_for_profile_swaps_atomically(tmp_path, monkeypatch): from api import gateway_watcher as gw from api import profiles target_home = (tmp_path / "target").resolve() created = [] seen = [] class FakeWatcher: def __init__(self, *, profile_name="", hermes_home=None, state_db_path=None): self.profile_name = profile_name self.hermes_home = hermes_home self.state_db_path = state_db_path self.started = False self.stopped = False created.append(self) def start(self): self.started = True if len(seen) == 0: seen.append(gw.get_watcher(profile_name="target", hermes_home=target_home)) def is_alive(self): return self.started def stop(self): self.stopped = True self.started = False existing = FakeWatcher(profile_name="target", hermes_home=target_home) existing.started = True monkeypatch.setattr(gw, "_watchers", {str(target_home): existing}) monkeypatch.setattr(gw, "GatewayWatcher", FakeWatcher) monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda name: target_home) watcher = gw.restart_watcher_for_profile("target") assert len(created) == 2 assert seen == [existing] assert existing.stopped is True assert watcher is created[-1] assert gw.get_watcher(profile_name="target", hermes_home=target_home) is watcher def test_watcher_registry_key_uses_concrete_values(tmp_path, monkeypatch): from api import gateway_watcher as gw def fail_resolve(**kwargs): raise AssertionError("_watcher_registry_key should not resolve profile state") monkeypatch.setattr(gw, "_resolve_watcher_target", fail_resolve) assert gw._watcher_registry_key("work", tmp_path / "work") == str((tmp_path / "work").resolve()) assert gw._watcher_registry_key("default", None) == "default" def test_start_watcher_pins_active_profile_home(tmp_path, monkeypatch): from api import gateway_watcher as gw from api import profiles profile_home = tmp_path / "active-profile" created = [] class FakeWatcher: def __init__(self, *, profile_name="", hermes_home=None, state_db_path=None): self.profile_name = profile_name self.hermes_home = hermes_home self.state_db_path = state_db_path self.started = False created.append(self) def start(self): self.started = True def is_alive(self): return self.started monkeypatch.setattr(gw, "_watchers", {}) monkeypatch.setattr(gw, "GatewayWatcher", FakeWatcher) monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "work") monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda name: profile_home) gw.start_watcher() assert len(created) == 1 assert created[0].profile_name == "work" assert created[0].hermes_home == profile_home assert created[0].started is True assert gw.get_watcher() is created[0] def test_get_watcher_scopes_lookup_to_active_profile(tmp_path, monkeypatch): from api import gateway_watcher as gw from api import profiles default_home = (tmp_path / "default").resolve() work_home = (tmp_path / "work").resolve() class FakeWatcher: def __init__(self, *, profile_name="", hermes_home=None, state_db_path=None): self.profile_name = profile_name self.hermes_home = hermes_home self.started = True def is_alive(self): return True default_watcher = FakeWatcher(profile_name="default", hermes_home=default_home) work_watcher = FakeWatcher(profile_name="work", hermes_home=work_home) monkeypatch.setattr( gw, "_watchers", { str(default_home): default_watcher, str(work_home): work_watcher, }, ) monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "work") monkeypatch.setattr( profiles, "get_hermes_home_for_profile", lambda name: work_home if name == "work" else default_home, ) assert gw.get_watcher() is work_watcher def test_profile_switch_restarts_watcher_best_effort(monkeypatch): from api import config, gateway_watcher, profiles, routes calls = [] monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr(routes, "read_body", lambda handler: {"name": "demo"}) monkeypatch.setattr(profiles, "_validate_profile_name", lambda name: None) monkeypatch.setattr(profiles, "switch_profile", lambda name, process_wide=False: {"ok": True, "name": name}) monkeypatch.setattr(config, "invalidate_models_cache", lambda: calls.append("cache")) monkeypatch.setattr(gateway_watcher, "restart_watcher_for_profile", lambda name: calls.append(("watcher", name))) handler = _FakeHandler() routes.handle_post(handler, urlparse("/api/profile/switch")) assert handler.status == 200 assert handler.get_json() == {"ok": True, "name": "demo"} assert calls == ["cache", ("watcher", "demo")] assert any(k == "Set-Cookie" for k, _v in handler.sent_headers) def test_profile_switch_response_survives_watcher_restart_failure(monkeypatch): from api import config, gateway_watcher, profiles, routes monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr(routes, "read_body", lambda handler: {"name": "demo"}) monkeypatch.setattr(profiles, "_validate_profile_name", lambda name: None) monkeypatch.setattr(profiles, "switch_profile", lambda name, process_wide=False: {"ok": True, "name": name}) monkeypatch.setattr(config, "invalidate_models_cache", lambda: None) monkeypatch.setattr( gateway_watcher, "restart_watcher_for_profile", lambda name: (_ for _ in ()).throw(RuntimeError("restart failed")), ) handler = _FakeHandler() routes.handle_post(handler, urlparse("/api/profile/switch")) assert handler.status == 200 assert handler.get_json() == {"ok": True, "name": "demo"} def test_subscribe_after_stop_gets_sentinel_immediately(): """Stop-race safety (#3629 / Codex gate): a subscriber added AFTER stop() has already set _stop_event and drained the then-current subscriber list must still receive the None sentinel — otherwise the SSE loop hangs open with keepalives but no events (it never learns the watcher it attached to was reaped during a concurrent profile switch).""" from api import gateway_watcher as gw watcher = gw.GatewayWatcher(hermes_home=None, profile_name="race") # Simulate the reaped/stopped watcher: stop() ran before this subscribe(). watcher.stop() q = watcher.subscribe() # The late subscriber must find the sentinel already queued (no block / hang). assert q.get_nowait() is None