100 lines
3.6 KiB
Python
100 lines
3.6 KiB
Python
"""Tests for ``_wait_for_runner_client`` crash-report short-circuit.
|
|
|
|
A runner that the daemon reports dead (``host.runner_exited`` →
|
|
``RunnerExitReports``) can never connect, so the runner-connect wait must
|
|
end the instant that report appears rather than burning the full timeout.
|
|
This is what turns a crashed-runner message from "appears ~33s later" into
|
|
"appears as soon as we're convinced the runner is busted".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from omnigent.server.host_registry import RunnerExitReports
|
|
from omnigent.server.routes.sessions import _wait_for_runner_client
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
class _NeverConnectsRegistry:
|
|
"""Tunnel registry stand-in whose runner never connects.
|
|
|
|
``wait_for_runner`` blocks for the full timeout then reports ``None``
|
|
(the real "timed out" outcome), so any early return must come from the
|
|
crash-report short-circuit, not from the connect signal.
|
|
|
|
:param waited: Records ``(runner_id, timeout_s)`` of each wait so the
|
|
test can assert the wait was actually attempted.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize with an empty wait log."""
|
|
self.waited: list[tuple[str, float]] = []
|
|
|
|
async def wait_for_runner(self, runner_id: str, *, timeout_s: float) -> None:
|
|
"""Block for the timeout, then report no connection.
|
|
|
|
:param runner_id: Runner id being awaited.
|
|
:param timeout_s: Max seconds the caller allotted.
|
|
:returns: ``None`` — the runner never connects.
|
|
"""
|
|
self.waited.append((runner_id, timeout_s))
|
|
await asyncio.sleep(timeout_s)
|
|
return
|
|
|
|
|
|
async def test_wait_short_circuits_when_runner_reported_dead() -> None:
|
|
"""A crash report ends the wait well before the timeout.
|
|
|
|
With a 5s timeout but a report already present, the wait must return
|
|
``None`` in a fraction of a second. A regression (ignoring the report)
|
|
would block the whole 5s — the asserted ceiling catches that.
|
|
"""
|
|
registry = _NeverConnectsRegistry()
|
|
reports = RunnerExitReports()
|
|
reports.record("runner_dead", "runner process exited with code 1", owner=None)
|
|
|
|
loop = asyncio.get_event_loop()
|
|
start = loop.time()
|
|
result = await _wait_for_runner_client(
|
|
"conv_x",
|
|
None, # runner_router unused on the report path (returns before resolve)
|
|
registry, # type: ignore[arg-type] — duck-typed wait_for_runner
|
|
runner_id="runner_dead",
|
|
timeout_s=5.0,
|
|
runner_exit_reports=reports,
|
|
)
|
|
elapsed = loop.time() - start
|
|
|
|
# Convicted busted → None, not a runner client.
|
|
assert result is None
|
|
# Returned on conviction, not after the 5s timeout. Generous ceiling
|
|
# (one poll interval is 0.25s) that still fails loudly on a regression.
|
|
assert elapsed < 1.0, f"wait did not short-circuit on the crash report (took {elapsed:.2f}s)"
|
|
|
|
|
|
async def test_wait_without_report_runs_to_timeout() -> None:
|
|
"""No report → the wait behaves as before (resolves at the timeout).
|
|
|
|
Guards against the short-circuit firing spuriously: a runner that is
|
|
merely slow to connect (no crash report) must still be waited for.
|
|
"""
|
|
registry = _NeverConnectsRegistry()
|
|
reports = RunnerExitReports() # empty — nothing reported dead
|
|
|
|
result = await _wait_for_runner_client(
|
|
"conv_x",
|
|
None,
|
|
registry, # type: ignore[arg-type]
|
|
runner_id="runner_slow",
|
|
timeout_s=0.1,
|
|
runner_exit_reports=reports,
|
|
)
|
|
|
|
# Timed out with no connection and no report → None, after waiting.
|
|
assert result is None
|
|
assert registry.waited == [("runner_slow", 0.1)]
|