Extract only backend admission/lifetime protection from #114565 (484b5d7), adapted to the existing DISPLAY identity and human-lease fences in #108914. No WSL selection, installer, config or UI changes.
227 lines
7.9 KiB
Python
227 lines
7.9 KiB
Python
"""Backend admission during Bot Screen rebind/release; extracted from #114565 for #108914."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import json
|
|
import threading
|
|
from concurrent.futures import Future
|
|
from contextlib import contextmanager
|
|
|
|
import pytest
|
|
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
from tools.bot_desktop import browser, lease, runtime as desktop
|
|
from tools.computer_use import tool as cu
|
|
from tools.computer_use_tool import registry
|
|
|
|
|
|
@contextmanager
|
|
def _profile(home):
|
|
token = set_hermes_home_override(str(home))
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
|
|
def _spawn(fn, name):
|
|
future = Future()
|
|
context = contextvars.copy_context()
|
|
|
|
def run():
|
|
try:
|
|
future.set_result(context.run(fn))
|
|
except BaseException as exc:
|
|
future.set_exception(exc)
|
|
|
|
thread = threading.Thread(target=run, name=name, daemon=True)
|
|
thread.start()
|
|
return thread, future
|
|
|
|
|
|
def _call():
|
|
return json.loads(registry.dispatch("computer_use", {"action": "list_apps"}, session_id="shared"))
|
|
|
|
|
|
@pytest.fixture
|
|
def runtime(tmp_path, monkeypatch):
|
|
homes = [tmp_path / "a", tmp_path / "b"]
|
|
for home, display in zip(homes, (":37", ":38")):
|
|
(home / "bot-desktop").mkdir(parents=True)
|
|
(home / "bot-desktop" / "env").write_text(f"DISPLAY={display}\n", encoding="utf-8")
|
|
(home / "config.yaml").write_text("computer_use:\n permission_mode: standard\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(homes[0]))
|
|
# Fake the device boundary only. Profile env publication, spawn identity,
|
|
# config, backend caches, dispatch and persisted human leases stay real.
|
|
monkeypatch.setattr(desktop, "_launcher_pid", lambda: 1)
|
|
monkeypatch.setattr(browser, "executable", lambda: None)
|
|
created = []
|
|
|
|
class Backend(cu._NoopBackend):
|
|
def __init__(self, mode):
|
|
super().__init__()
|
|
self.stopped = False
|
|
self.count = 0
|
|
self.before_action = lambda: None
|
|
created.append(self)
|
|
|
|
def stop(self):
|
|
self.stopped = True
|
|
|
|
def list_apps(self):
|
|
assert not self.stopped, "dispatch used a retired backend"
|
|
self.count += 1
|
|
self.before_action()
|
|
assert not self.stopped, "teardown ran during dispatch"
|
|
return [{"name": str(created.index(self))}]
|
|
|
|
cu.reset_backend_for_tests()
|
|
monkeypatch.setattr(cu, "_new_backend", Backend)
|
|
yield homes, created
|
|
cu.reset_backend_for_tests()
|
|
lease._reset_for_tests()
|
|
|
|
|
|
@pytest.mark.parametrize("pause_at", ["after_lookup", "before_acquire"])
|
|
@pytest.mark.parametrize("change", ["display", "mode", "release", "queued_display"])
|
|
def test_dispatch_rechecks_admission(runtime, monkeypatch, pause_at, change):
|
|
homes, created = runtime
|
|
paused, resume = threading.Event(), threading.Event()
|
|
real_get = cu._get_backend
|
|
old = real_get("shared")
|
|
old_lock = cu._backend_call_locks["shared"]
|
|
|
|
def pause():
|
|
if threading.current_thread().name == "waiting-call" and not paused.is_set():
|
|
paused.set()
|
|
assert resume.wait(10), "test did not release the waiting call"
|
|
|
|
def get(session_id=""):
|
|
backend = real_get(session_id)
|
|
if pause_at == "after_lookup":
|
|
pause()
|
|
return backend
|
|
|
|
class PausingLock:
|
|
def __enter__(self):
|
|
pause()
|
|
old_lock.acquire()
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
old_lock.release()
|
|
|
|
monkeypatch.setattr(cu, "_get_backend", get)
|
|
if pause_at == "before_acquire":
|
|
cu._backend_call_locks["shared"] = PausingLock()
|
|
worker, result = _spawn(_call, "waiting-call")
|
|
try:
|
|
assert paused.wait(10)
|
|
if change == "release":
|
|
assert cu.release_computer_use_session("shared")
|
|
else:
|
|
if change == "mode":
|
|
(homes[0] / "config.yaml").write_text("computer_use:\n permission_mode: bounded\n", encoding="utf-8")
|
|
else:
|
|
(homes[0] / "bot-desktop" / "env").write_text("DISPLAY=:39\n", encoding="utf-8")
|
|
if change != "queued_display":
|
|
assert real_get("shared") is not old
|
|
if change != "queued_display":
|
|
assert old.stopped
|
|
resume.set()
|
|
value = result.result(timeout=10)
|
|
assert "error" not in value, value
|
|
assert old.count == 0
|
|
assert sum(backend.count for backend in created) == 1
|
|
assert value["apps"] == [{"name": str(created.index(real_get("shared")))}]
|
|
finally:
|
|
resume.set()
|
|
worker.join(timeout=10)
|
|
assert not worker.is_alive()
|
|
|
|
|
|
@pytest.mark.parametrize("fail_action", [False, True])
|
|
def test_release_waits_without_blocking_another_profile_or_replaying(runtime, monkeypatch, fail_action):
|
|
homes, created = runtime
|
|
action_entered, finish_action, stop_attempted = (threading.Event() for _ in range(3))
|
|
threads = []
|
|
with _profile(homes[0]):
|
|
old = cu._get_backend("shared")
|
|
|
|
def action():
|
|
action_entered.set()
|
|
assert finish_action.wait(10)
|
|
if fail_action:
|
|
raise RuntimeError("action outcome is unknown; do not replay")
|
|
|
|
old.before_action = action
|
|
real_stop = cu._stop_backend
|
|
|
|
def stop(backend, lock, on_error):
|
|
if backend is old:
|
|
stop_attempted.set()
|
|
return real_stop(backend, lock, on_error)
|
|
|
|
monkeypatch.setattr(cu, "_stop_backend", stop)
|
|
try:
|
|
thread, result = _spawn(_call, "admitted-call")
|
|
threads.append(thread)
|
|
assert action_entered.wait(10)
|
|
thread, released = _spawn(lambda: cu.release_computer_use_session("shared"), "release")
|
|
threads.append(thread)
|
|
assert stop_attempted.wait(10)
|
|
assert not old.stopped and not released.done()
|
|
with _profile(homes[1]):
|
|
thread, other = _spawn(_call, "other-profile")
|
|
threads.append(thread)
|
|
assert "error" not in other.result(timeout=10)
|
|
assert not old.stopped
|
|
finish_action.set()
|
|
value = result.result(timeout=10)
|
|
assert ("error" in value) is fail_action
|
|
assert released.result(timeout=10) is True
|
|
assert old.stopped and old.count == 1
|
|
# A -> B -> A: the released profile gets its own fresh backend.
|
|
assert "error" not in _call()
|
|
assert cu._get_backend("shared") is not old
|
|
with _profile(homes[1]):
|
|
assert not cu._get_backend("shared").stopped
|
|
finally:
|
|
finish_action.set()
|
|
for thread in threads:
|
|
thread.join(timeout=10)
|
|
assert not thread.is_alive()
|
|
|
|
|
|
@pytest.mark.parametrize("hand_back", [False, True])
|
|
def test_backend_retry_preserves_the_original_human_lease_epoch(runtime, monkeypatch, hand_back):
|
|
_, created = runtime
|
|
paused, resume = threading.Event(), threading.Event()
|
|
real_get = cu._get_backend
|
|
old = real_get("shared")
|
|
|
|
def get(session_id=""):
|
|
backend = real_get(session_id)
|
|
if threading.current_thread().name == "waiting-call" and not paused.is_set():
|
|
paused.set()
|
|
assert resume.wait(10)
|
|
return backend
|
|
|
|
monkeypatch.setattr(cu, "_get_backend", get)
|
|
worker, result = _spawn(_call, "waiting-call")
|
|
try:
|
|
assert paused.wait(10)
|
|
assert cu.release_computer_use_session("shared")
|
|
assert old.stopped
|
|
lease.acquire("human")
|
|
if hand_back:
|
|
lease.release("human")
|
|
resume.set()
|
|
assert result.result(timeout=10)["code"] == "human_has_control"
|
|
assert all(backend.count == 0 for backend in created)
|
|
finally:
|
|
resume.set()
|
|
worker.join(timeout=10)
|
|
assert not worker.is_alive()
|