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.
164 lines
6.0 KiB
Python
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
|