The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.
Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.
Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
skip-on-Windows moves onto the 11 methods that had no host mark; the
11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
platforms("linux") in favour of the module's "posix" (mkfifo exists on
macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
or wider marks removed.
241 lines
10 KiB
Python
241 lines
10 KiB
Python
"""A Desktop-pooled ``hermes serve`` child must PROVE it is idle before the Desktop may retire it
|
|
for a foreground open (supersedes #104871, whose idle signal was renderer bookkeeping).
|
|
|
|
Occupied is not busy: the renderer keeps every pinned tile's backend keepalive-fresh forever, so
|
|
the Desktop needs the backend's own ledgers — running sessions, running cron jobs (the desktop
|
|
child runs the in-process ticker), and prompts waiting on a human. Anything unreadable is
|
|
``None`` and the caller treats it as busy.
|
|
"""
|
|
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.web_server_idle_proof import idle_proof, pending_human_input
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def test_idle_proof_is_true_only_when_every_ledger_is_provably_empty():
|
|
assert idle_proof(turn_probe=lambda: False, input_probe=lambda: 0) == {"idle": True, "reason": None}
|
|
assert idle_proof(turn_probe=lambda: True, input_probe=lambda: 0) == {
|
|
"idle": False, "reason": "turn_in_flight", "detail": None}
|
|
assert idle_proof(turn_probe=lambda: "session:abc", input_probe=lambda: 0)["detail"] == "session:abc"
|
|
assert idle_proof(turn_probe=lambda: False, input_probe=lambda: 1) == {
|
|
"idle": False, "reason": "awaiting_human_input"}
|
|
# Fail closed: an indeterminate probe is never reported as idle.
|
|
assert idle_proof(turn_probe=lambda: None, input_probe=lambda: 0)["idle"] is None
|
|
assert idle_proof(turn_probe=lambda: False, input_probe=lambda: None)["idle"] is None
|
|
|
|
|
|
def test_idle_proof_reads_the_real_cron_and_human_input_ledgers():
|
|
"""The probe must see a running cron job (invisible to the renderer) and a pending approval
|
|
or open clarify request in the live process ledgers, not a renderer-published flag."""
|
|
import cron.scheduler as scheduler
|
|
from tools import approval
|
|
from tui_gateway import server_requests
|
|
|
|
assert idle_proof()["idle"] is True
|
|
|
|
with scheduler._running_lock:
|
|
scheduler._running_job_ids.add(scheduler._inflight_key("idle-proof-live-job"))
|
|
try:
|
|
# The busy verdict names the ledger and the job, so a backend that will not retire is diagnosable.
|
|
assert idle_proof() == {"idle": False, "reason": "turn_in_flight", "detail": "cron:idle-proof-live-job"}
|
|
finally:
|
|
with scheduler._running_lock:
|
|
scheduler._running_job_ids.discard(scheduler._inflight_key("idle-proof-live-job"))
|
|
|
|
from tools.approval_gateway_wait import _ApprovalEntry
|
|
|
|
entry = _ApprovalEntry({"request_id": "idle-proof-approval"})
|
|
with approval._lock:
|
|
approval._gateway_queues.setdefault("idle-proof-session", []).append(entry)
|
|
try:
|
|
assert pending_human_input() == 1
|
|
assert idle_proof() == {"idle": False, "reason": "awaiting_human_input"}
|
|
finally:
|
|
with approval._lock:
|
|
approval._gateway_queues.pop("idle-proof-session", None)
|
|
|
|
req = server_requests.ServerRequest("sid-idle-proof", "clarify", {})
|
|
with server_requests._lock:
|
|
server_requests._open[req.id] = req
|
|
try:
|
|
assert idle_proof()["idle"] is False
|
|
finally:
|
|
with server_requests._lock:
|
|
server_requests._open.pop(req.id, None)
|
|
|
|
assert idle_proof()["idle"] is True
|
|
|
|
|
|
def test_queued_prompts_and_unwinding_workers_are_not_idle(monkeypatch):
|
|
from tui_gateway import server
|
|
|
|
session = {"running": False, "history_lock": threading.RLock()}
|
|
monkeypatch.setitem(server._sessions, "queued-retirement", session)
|
|
server._enqueue_prompt(session, "accepted follow-up", None)
|
|
assert idle_proof()["idle"] is False
|
|
session.pop("queued_prompt")
|
|
release = threading.Event()
|
|
thread = threading.Thread(target=lambda: release.wait(10), daemon=True)
|
|
session["_run_thread"] = thread
|
|
thread.start()
|
|
try:
|
|
assert idle_proof()["idle"] is False
|
|
finally:
|
|
release.set()
|
|
thread.join(10)
|
|
assert idle_proof()["idle"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Live children: three real desktop-shaped ``serve`` processes, one held busy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
pytestmark_live = pytest.mark.platforms("posix") # POSIX serve-runner path under test
|
|
|
|
TOKEN = "idle-proof-live-token"
|
|
|
|
|
|
def _spawn_desktop_child(tmp_path: Path, name: str, *, busy: bool) -> subprocess.Popen:
|
|
"""Mirror the Desktop pool spawn: ``HERMES_DESKTOP=1`` (in-process cron ticker), a per-child
|
|
HERMES_HOME, an ephemeral port, the session token the Desktop probes with. The busy child
|
|
registers a cron run in the scheduler's running-job ledger before it serves — the same
|
|
ledger a real fire uses and the one the probe reads."""
|
|
home = tmp_path / name
|
|
home.mkdir()
|
|
env = dict(os.environ)
|
|
env.pop("PYTHONPATH", None)
|
|
for k in ("HERMES_PARENT_PID", "HERMES_PARENT_START_MARKER", "HERMES_PARENT_NONCE"):
|
|
env.pop(k, None)
|
|
env.update(
|
|
HERMES_HOME=str(home),
|
|
HERMES_SERVE_HEADLESS="1",
|
|
HERMES_DESKTOP="1",
|
|
HERMES_DASHBOARD_SESSION_TOKEN=TOKEN,
|
|
PYTHONUNBUFFERED="1",
|
|
)
|
|
hold = (
|
|
"import cron.scheduler as s\n"
|
|
"with s._running_lock:\n"
|
|
" s._running_job_ids.add(s._inflight_key('live-idle-proof-job'))\n"
|
|
) if busy else ""
|
|
code = (
|
|
hold
|
|
+ "from hermes_cli.web_server import start_server\n"
|
|
"start_server(host='127.0.0.1', port=0, open_browser=False, headless=True)\n"
|
|
)
|
|
return subprocess.Popen(
|
|
[sys.executable, "-c", code], cwd=str(REPO_ROOT), env=env,
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
|
)
|
|
|
|
|
|
def _read_until(proc: subprocess.Popen, token: str, timeout: float = 120.0):
|
|
lines: list[str] = []
|
|
hit = threading.Event()
|
|
|
|
def _pump():
|
|
assert proc.stdout is not None
|
|
for line in proc.stdout:
|
|
lines.append(line)
|
|
if token in line:
|
|
hit.set()
|
|
return
|
|
|
|
threading.Thread(target=_pump, daemon=True).start()
|
|
hit.wait(timeout)
|
|
return hit.is_set(), lines
|
|
|
|
|
|
def _probe_settled(port: int, settled, timeout: float = 20.0) -> tuple[int, dict]:
|
|
"""The probe is stable BETWEEN ticks, not at every instant: the in-process cron ticker holds
|
|
retirement admission for its whole scan (#98745), so a single sample can say
|
|
``retirement_admission`` for an idle resident, or name the admission instead of the cron ledger
|
|
for a busy child. Poll until *settled(body)* or the window closes; the last verdict is returned
|
|
either way, so a genuinely wrong state still fails the assertion."""
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
verdict = _probe(port)
|
|
if settled(verdict[1]) or time.monotonic() >= deadline:
|
|
return verdict
|
|
time.sleep(0.25)
|
|
|
|
|
|
def _probe(port: int, token: str | None = TOKEN) -> tuple[int, dict]:
|
|
req = urllib.request.Request(f"http://127.0.0.1:{port}/api/health/idle",
|
|
headers={"X-Hermes-Session-Token": token} if token else {})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
return resp.status, json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as exc:
|
|
return exc.code, {}
|
|
|
|
|
|
def _await_verdict(port: int, accept, timeout: float = 60.0) -> tuple[tuple[int, dict], list]:
|
|
"""Probe until ``accept(verdict)`` or the deadline; returns the last verdict and every one seen."""
|
|
deadline = time.monotonic() + timeout
|
|
seen: list[tuple[int, dict]] = []
|
|
while True:
|
|
verdict = _probe(port)
|
|
seen.append(verdict)
|
|
if accept(verdict) or time.monotonic() >= deadline:
|
|
return verdict, seen
|
|
time.sleep(0.2)
|
|
|
|
|
|
@pytestmark_live
|
|
def test_live_pooled_children_prove_idle_or_busy_over_the_desktop_probe(tmp_path):
|
|
"""Three real children at the Desktop's pool cap; exactly one is busy. The Desktop's probe
|
|
must get ``idle: false`` from the busy one and ``idle: true`` from the two residents that are
|
|
merely occupied. The probe is token-gated: an unauthenticated caller learns nothing."""
|
|
children = {
|
|
"resident-a": _spawn_desktop_child(tmp_path, "resident-a", busy=False),
|
|
"resident-b": _spawn_desktop_child(tmp_path, "resident-b", busy=False),
|
|
"cron-busy": _spawn_desktop_child(tmp_path, "cron-busy", busy=True),
|
|
}
|
|
try:
|
|
ports = {}
|
|
for name, proc in children.items():
|
|
ready, lines = _read_until(proc, "HERMES_BACKEND_READY")
|
|
assert ready, f"{name}: no READY sentinel; output:\n{''.join(lines)}"
|
|
ready_line = next(l for l in lines if "HERMES_BACKEND_READY" in l)
|
|
ports[name] = int(ready_line.strip().rsplit("port=", 1)[1])
|
|
|
|
# READY precedes quiescence: the desktop child's cron ticker runs its first tick right at
|
|
# start and holds retirement admission through the whole scan (``retirement_admission``),
|
|
# which on a loaded runner outlasts the first probe. A resident is "provably idle" once that
|
|
# startup work drains, so poll until the verdict settles instead of sampling once.
|
|
verdicts = {name: _probe_settled(port, lambda b: b.get("idle") is True)
|
|
for name, port in ports.items() if name != "cron-busy"}
|
|
verdicts["cron-busy"], busy_seen = _await_verdict(
|
|
ports["cron-busy"], lambda v: str(v[1].get("detail", "")).startswith("cron:"))
|
|
assert verdicts["resident-a"] == (200, {"ok": True, "idle": True, "reason": None}), verdicts
|
|
assert verdicts["resident-b"] == (200, {"ok": True, "idle": True, "reason": None}), verdicts
|
|
assert verdicts["cron-busy"] == (200, {
|
|
"ok": True, "idle": False, "reason": "turn_in_flight", "detail": "cron:live-idle-proof-job"}), verdicts
|
|
# Whatever startup work shadowed the cron ledger, the busy child never once claimed idle.
|
|
assert all(body.get("idle") is False for _status, body in busy_seen), busy_seen
|
|
|
|
status, body = _probe(ports["resident-a"], token=None)
|
|
assert status == 401 and "idle" not in body
|
|
finally:
|
|
for proc in children.values():
|
|
proc.terminate()
|
|
for proc in children.values():
|
|
try:
|
|
proc.wait(timeout=30)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|