From f720cbb14cf69a03c355dc107cbffe4022a41bd2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:43 -0700 Subject: [PATCH] fix(browser): shutdown closes each browser session under the profile that owns it cleanup_all_browsers (gateway shutdown, atexit) ran every teardown in the launch profile's scope, unlike the janitor: a multiplexed profile's cloud session was closed through the launch profile's provider (usually none), so it stayed open and billing until the provider's own timeout. Each session is now torn down under its recorded owner home and secret scope. --- .../tools/test_browser_cleanup_owner_scope.py | 52 +++++++++++++++++++ tools/browser_session_lifecycle.py | 11 +++- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_browser_cleanup_owner_scope.py 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