hermes-agent/tests/docker/test_profile_gateway.py
Teknium 0f56e20d23
test: de-flake 30 timing-sensitive test files for loaded CI runners
Root-cause fixes from the flake audit (session-DB mining + repo sweep):

Event-based sync instead of sleep-sync:
- title_generator: mock sets threading.Event, wait(10) replaces
  sleep(0.3) hoping the daemon thread got scheduled
- docker zombie_reaping / profile_gateway: poll-for-state helpers
  replace fixed 1-3s sleeps (s6 transitions + SIGCHLD reaping are async)
- process_registry tree test: select()-bounded readline replaces an
  unbounded blocking read (parent wedge now fails THIS test with a clear
  message instead of an opaque rc=124 file kill); SIGTERM grace 1s->2s
  (the 1s partition window mid-interpreter-startup is how a child PID
  escaped the live-system guard in CI)

Timeout raises (loaded 8-way-sliced runners see ~5s scheduling floors;
all of these complete in ms-to-1s when healthy so the raises cost
nothing on green runs):
- subprocess/thread waits <= 2s raised to 10-15s across mcp_tool,
  mcp_circuit_breaker, mcp_reconnect_retry_reset, mcp_parked_self_probe,
  mcp_cancelled_error_propagation, registry, clarify_gateway, interrupt,
  voice_cli_integration, docker_environment, session_store_lock_io,
  planned_stop_watcher, cli_interrupt_subagent, thread_scoped_output
  (joins now also assert not is_alive() so stragglers fail loudly)
- wall-clock discrimination ceilings loosened where the guarded hang is
  10x larger: local_background_child_hang 4s->10s, interrupt_cleanup
  setup 5s->20s + pgid-exit 30s->60s, mcp_stability grandchild spinup
  5s->15s, protocol/gil-starvation fast-handler 0.5s->2s,
  iso_certify_seam 1.5s->5s, wait_for_mcp_discovery 0.1s->1s
- narrow assertion windows widened: honcho first-turn wait 0.4..0.65 ->
  0.25..2.0 (property is bounded-not-hung, not an exact wall-clock);
  compression fork-lock TTL 1s->3s (12 refresh chances per lease);
  compression-lock expiry margins symmetric (ttl 0.05->0.5, sleep 1.0)
- telegram hung-DNS bound 1.0->1.4 (fake hang is 1.5s — must stay under)
2026-07-17 13:54:04 -07:00

143 lines
No EOL
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Harness: per-profile gateway start/stop inside the container.
Phase 4 wires `hermes -p <profile> gateway start/stop` through the s6
ServiceManager dispatch path inside the container — so the lifecycle
commands now bring up an s6-supervised gateway rather than refusing
with the pre-Phase-4 informational message.
These tests were marked ``xfail(strict=True)`` through Phase 03 and
flip to plain ``test_…`` once Phase 4 lands (now).
NB: The harness profile has no model/auth configured. Depending on
how the gateway run script handles missing config, the supervised
process may either spin up successfully (and svstat reports ``up``)
or exit fast and get throttled by s6 (and svstat reports ``down …,
want up``). Both states are valid "user asked for gateway up" results
— what we assert is the *want* intent the lifecycle command set, NOT
the supervised process's health. ``s6-svc -u`` records ``want up`` in
the supervise/status file regardless of the run-script outcome.
Every ``docker exec`` here runs as the unprivileged ``hermes`` user
(via :func:`docker_exec_sh` in conftest); see the conftest module
docstring.
"""
from __future__ import annotations
import subprocess
import time
from tests.docker.conftest import docker_exec_sh, start_container
PROFILE = "test-harness-profile"
def _sh(
container: str, command: str, timeout: int = 30,
) -> subprocess.CompletedProcess[str]:
return docker_exec_sh(container, command, timeout=timeout)
def _svstat(container: str) -> str:
"""Returns the raw s6-svstat output for the test profile's slot.
/command/s6-svstat is called by absolute path because /command/
isn't on PATH for docker-exec sessions."""
r = _sh(container, f"/command/s6-svstat /run/service/gateway-{PROFILE}")
return r.stdout if r.returncode == 0 else ""
def _svstat_wants_up(container: str) -> bool:
"""Read the slot's want-state from s6-svstat output.
s6-svstat formats the output to elide redundancies — when the
service is currently up AND s6 wants it up, the literal token
``want up`` doesn't appear (it's implicit from the leading ``up``).
When the service is down but s6 wants it back up, ``, want up``
appears explicitly. So a comprehensive "is the want-intent set to
up" check has to accept both spellings.
"""
state = _svstat(container)
if not state:
return False
head = state.split()[0] if state.split() else ""
if head == "up":
# Currently up implies wanted-up unless ``want down`` is set.
return "want down" not in state
# Currently down — ``want up`` only shows up when explicitly set.
return "want up" in state
def _wait_for_want_state(container_name: str, want_up: bool, timeout: float = 15.0) -> None:
"""Poll s6 want-state until it matches, instead of a fixed sleep.
s6 state transitions are asynchronous; fixed two-second sleeps flaked
on loaded CI hosts.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if _svstat_wants_up(container_name) == want_up:
return
time.sleep(0.5)
state = "up" if want_up else "down"
raise AssertionError(
f"slot want-state never became {state} within {timeout}s: "
f"{_svstat(container_name)!r}"
)
def test_profile_create_then_gateway_start(
built_image: str, container_name: str,
) -> None:
start_container(built_image, container_name, cmd="sleep 120")
r = _sh(container_name, f"hermes profile create {PROFILE}")
assert r.returncode == 0, f"profile create failed: {r.stderr}"
# Profile create's s6-register hook should have produced a service slot.
r = _sh(container_name, f"test -d /run/service/gateway-{PROFILE}")
assert r.returncode == 0, "s6 service slot not created on profile create"
r = _sh(container_name, f"hermes -p {PROFILE} gateway start", timeout=60)
assert r.returncode == 0, (
f"gateway start failed: stderr={r.stderr!r} stdout={r.stdout!r}"
)
# After start, s6's intent is "up" — even if the supervised gateway
# process spin-fails (no model/auth in the test profile), the
# supervision-state contract holds. See ``_svstat_wants_up`` for
# why we accept both ``up …`` (currently up) and ``down …, want
# up`` (down but s6 wants up).
_wait_for_want_state(container_name, want_up=True)
r = _sh(container_name, f"hermes -p {PROFILE} gateway stop", timeout=30)
assert r.returncode == 0
_wait_for_want_state(container_name, want_up=False)
def test_profile_delete_stops_gateway(
built_image: str, container_name: str,
) -> None:
"""Deleting a profile should stop its gateway and remove the s6
service slot."""
start_container(built_image, container_name, cmd="sleep 120")
_sh(container_name, f"hermes profile create {PROFILE}")
_sh(container_name, f"hermes -p {PROFILE} gateway start", timeout=60)
_wait_for_want_state(container_name, want_up=True)
r = _sh(
container_name,
f"hermes profile delete {PROFILE} --yes",
timeout=30,
)
assert r.returncode == 0, f"profile delete failed: {r.stderr}"
# Poll for slot removal instead of a fixed sleep.
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
r = _sh(container_name, f"test -d /run/service/gateway-{PROFILE}")
if r.returncode != 0:
break
time.sleep(0.5)
assert r.returncode != 0, "s6 service slot still present after profile delete"