Files
hermes-agent/tests/tools/test_browser_supervisor_capture_dispatch.py
Teknium 66e0cee526 fix(browser): end timeout=None captured waits when the supervisor loop closes
A stop racing CapturedCDP.call() can close the supervisor loop before the
queued _send runs; run_coroutine_threadsafe's future then never resolves and
fut.result(timeout=None) hangs the consumer thread forever. Poll the unbounded
wait and raise CapturedCDPInvalid once the loop is closed. Fold the salvaged
validation tests into the dispatch file (one fixture, no F811), and tighten
them: the deadline test stalls inside the result grace, socket close asserts
CapturedCDPInvalid, the async validator asserts the refusal message.
2026-10-09 02:04:29 -07:00

164 lines
6.0 KiB
Python

"""Dispatch/lossless-wait extension exercised through the real public capture API."""
import asyncio
import concurrent.futures
import json
import threading
import time
import pytest
from tests.tools.test_browser_supervisor_capture import _FakeCDP, _wait
from tools.browser_supervisor import _SupervisorRegistry
from tools.browser_supervisor_capture import CapturedCDPInvalid
class DelayedCDP(_FakeCDP):
async def _handle(self, ws):
self.connections.append(ws)
conn = len(self.connections)
async for raw in ws:
msg = json.loads(raw)
method, sid = msg['method'], msg.get('sessionId')
self.frames.append((conn, method, sid))
result = {'echo':method}
if method == 'Target.getTargets':
result = {'targetInfos':[{'targetId':'fixture-parent','type':'page','url':'about:blank'}]}
elif method == 'Target.attachToTarget':
if msg.get('params',{}).get('targetId') == 'late-child':
await asyncio.sleep(.2)
result = {'sessionId':'late-owned-session'}
else:
result = {'sessionId':'default-session'}
await ws.send(json.dumps({'id':msg['id'],'result':result}))
@pytest.fixture
def pair():
server, registry = DelayedCDP(), _SupervisorRegistry()
sup = registry.get_or_start('fixture', server.url, start_timeout=5)
try:
yield server, registry, sup, registry.capture('fixture')
finally:
registry.stop_all()
def test_unbounded_reply_retains_late_attachment_for_consumer_cleanup(pair):
server, registry, sup, handle = pair
abandoned = threading.Event()
outcomes = []
def worker():
result = handle.call('Target.attachToTarget', {'targetId':'late-child','flatten':True}, timeout=None)
outcomes.append(result['result']['sessionId'])
if abandoned.is_set():
handle.call('Target.detachFromTarget', {'sessionId':outcomes[-1]})
thread = threading.Thread(target=worker)
thread.start()
_wait(lambda: len([m for m in server.methods_on(1) if m == 'Target.attachToTarget']) == 2)
abandoned.set()
thread.join(3)
assert not thread.is_alive()
assert outcomes == ['late-owned-session']
assert server.methods_on(1).count('Target.detachFromTarget') == 1
def test_dispatch_deadline_refuses_queued_command_after_timeout(pair):
server, registry, sup, handle = pair
entered = threading.Event()
def block():
# Stall the loop past the caller's deadline but inside its +1s result grace.
entered.set()
time.sleep(.4)
sup._loop.call_soon_threadsafe(block)
assert entered.wait(1)
with pytest.raises(TimeoutError):
handle.call('Probe.expired', timeout=.1)
handle.call('Target.getTargets', timeout=2)
assert 'Probe.expired' not in server.methods_on(1)
def test_before_send_checks_cancellation_on_supervisor_loop(pair):
server, registry, sup, handle = pair
entered, release, abandoned = threading.Event(), threading.Event(), threading.Event()
failures = []
def block():
entered.set()
release.wait(4)
def check():
assert asyncio.get_running_loop() is sup._loop
if abandoned.is_set():
raise ValueError('fixture cancelled')
def caller():
try:
handle.call('Probe.cancelled', timeout=2, before_send=check)
except BaseException as exc:
failures.append(exc)
sup._loop.call_soon_threadsafe(block)
assert entered.wait(1)
thread = threading.Thread(target=caller)
thread.start()
abandoned.set()
release.set()
thread.join(3)
assert failures and isinstance(failures[0], ValueError)
assert 'Probe.cancelled' not in server.methods_on(1)
def test_unbounded_reply_fails_promptly_on_socket_close(pair):
server, registry, sup, handle = pair
failures = []
# This fixture replies to ordinary methods; block the connection handler on attach instead.
def attach():
try:
handle.call('Target.attachToTarget', {'targetId':'late-child'}, timeout=None)
except BaseException as exc:
failures.append(exc)
thread = threading.Thread(target=attach)
thread.start()
_wait(lambda: server.methods_on(1).count('Target.attachToTarget') == 2)
server.drop_current()
thread.join(3)
assert not thread.is_alive()
assert failures and isinstance(failures[0], CapturedCDPInvalid)
def test_unbounded_wait_ends_when_loop_closes_with_dispatch_queued(pair, monkeypatch):
server, registry, sup, handle = pair
import agent.async_utils as async_utils
# A stop racing call() can close the loop before its queued dispatch runs; the
# scheduled future then never resolves.
real = async_utils.safe_schedule_threadsafe
def dropped(coro, loop, **kw):
if coro.__qualname__ != 'CapturedCDP._send':
return real(coro, loop, **kw)
coro.close()
return concurrent.futures.Future()
monkeypatch.setattr(async_utils, 'safe_schedule_threadsafe', dropped)
failures = []
def caller():
try:
handle.call('Probe.dropped', timeout=None)
except BaseException as exc:
failures.append(exc)
thread = threading.Thread(target=caller)
thread.start()
registry.stop_all()
thread.join(4)
assert not thread.is_alive()
assert failures and isinstance(failures[0], CapturedCDPInvalid)
@pytest.mark.parametrize('timeout', [0, -1, float('inf'), float('nan'), 1e300, True, '5'])
def test_invalid_timeout(pair, timeout):
server, _, _, cap = pair
before = list(server.methods_on(1))
with pytest.raises(ValueError):
cap.call('Probe.invalid', timeout=timeout)
assert server.methods_on(1) == before
def test_async_validator_refuses(pair):
server, _, _, cap = pair
before = list(server.methods_on(1))
async def check():
return None
with pytest.raises(TypeError, match='synchronously'):
cap.call('Probe.async', before_send=check)
assert server.methods_on(1) == before