diff --git a/tests/tools/test_browser_cleanup_owner_scope.py b/tests/tools/test_browser_cleanup_owner_scope.py new file mode 100644 index 0000000000..801402d17d --- /dev/null +++ b/tests/tools/test_browser_cleanup_owner_scope.py @@ -0,0 +1,52 @@ +"""Shutdown teardown runs each session under the profile that owns it. + +``cleanup_all_browsers`` (gateway shutdown, atexit) runs outside any profile scope, like the janitor. A cloud +session belongs to the provider and credentials of the profile that created it: closed under the launch +profile's scope instead, a multiplexed profile's Browserbase / Browser Use session is never closed (the launch +profile has no such provider) and keeps billing until the provider's own timeout. +""" + +import pytest + +from hermes_constants import get_hermes_home +from tools import browser_launcher as launcher +from tools import browser_session as bs +from tools import browser_session_cloud +from tools import browser_session_lifecycle as lifecycle + + +@pytest.fixture +def isolated_sessions(monkeypatch): + for name in ("_active_sessions", "_session_last_activity", "_session_owner_homes", "_cleanup_failures"): + monkeypatch.setattr(bs, name, {}) + monkeypatch.setattr(launcher, "_browsers", {}) + monkeypatch.setattr(lifecycle, "start_cleanup_thread", lambda: None) + return bs._active_sessions + + +class _ProfileProvider: + """The cloud provider the CURRENT profile scope resolves, recording which profile closed what.""" + + closed: list = [] + + def __init__(self): + self.home = str(get_hermes_home()) + + def close_session(self, session_id): + _ProfileProvider.closed.append((self.home, session_id)) + + +def test_shutdown_closes_each_cloud_session_under_its_owning_profile(tmp_path, monkeypatch, isolated_sessions): + launch, routed = tmp_path / "launch", tmp_path / "routed" + for home in (launch, routed): + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setattr(_ProfileProvider, "closed", []) + monkeypatch.setattr(browser_session_cloud, "get_cloud_provider", _ProfileProvider) + isolated_sessions["task@routed"] = {"session_name": "bb_1", "bb_session_id": "bb-routed", "features": {}, + "cdp_url": "wss://cloud.example/x"} + bs._session_owner_homes["task@routed"] = str(routed) + + lifecycle.cleanup_all_browsers() + + assert _ProfileProvider.closed == [(str(routed), "bb-routed")] diff --git a/tools/browser_session_lifecycle.py b/tools/browser_session_lifecycle.py index 68b05e8a9e..8f5ebdaf3a 100644 --- a/tools/browser_session_lifecycle.py +++ b/tools/browser_session_lifecycle.py @@ -196,13 +196,20 @@ def cleanup_profile_browsers(home_key: str) -> None: reset_caches(home_key) +def _cleanup_in_owner_scope(cache_key: str) -> None: + with _session_owner_scope(cache_key): + cleanup_single_session(cache_key) + + def cleanup_all_browsers() -> None: - """Close every session (gateway shutdown), stop every browser this process launched, reset caches.""" + """Close every session (gateway shutdown), stop every browser this process launched, reset caches. Each + session is closed under its owning profile, as the janitor does: a cloud session belongs to that profile's + provider and credentials.""" bs = _bs() with bs._cleanup_lock: keys = list(bs._active_sessions) for key in keys: - _best_effort(f"browser cleanup for {key}", lambda key=key: cleanup_single_session(key)) + _best_effort(f"browser cleanup for {key}", lambda key=key: _cleanup_in_owner_scope(key)) def _stop_supervisors(): from tools.browser_supervisor import SUPERVISOR_REGISTRY