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.
This commit is contained in:
Teknium committed 2026-10-23 01:43:16 -07:00
1 parent 0083c92f38
commit f720cbb14c
2 files changed
+61 -2

No files matched your search

@@ -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")]
+9 -2
View File
@@ -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