Baseline merge head e5ff26a: 28144 stmts, 2807 missing, exact 90.0263%, margin +7. After thickening (full run): 2350 missing, exact 91.6501%, margin +464. Full suite 3516 items: 3509 pass, 1 failure (pre-existing November date failure, untouched), 6 skipped. No production code changed.
234 lines
8.4 KiB
Python
234 lines
8.4 KiB
Python
"""Coverage batch v4-D5: host.py leftover branches (wait-turn first
|
|
slice, run-turn interrupt raise, daemon-stop inner break, daemon
|
|
subcommand failure, interrupt poll paths, goal verify warning).
|
|
|
|
Stubbing discipline: the function under test is never stubbed. Patched
|
|
collaborators are: time.monotonic / time.sleep / os.kill /
|
|
subprocess.run / interrupt_turn / _wait_turn / dispatch /
|
|
run_goal_loop / json.load (sinks, clocks and predicates). Every
|
|
assertion reads a real return value or real stdout.
|
|
"""
|
|
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
import queue
|
|
import signal
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
import host
|
|
from host import Server, _wait_turn
|
|
|
|
|
|
def _server():
|
|
srv = object.__new__(Server)
|
|
srv.proc = None
|
|
srv.codex_bin = "codex"
|
|
srv.codex_home = None
|
|
srv.approval = "deny"
|
|
srv.model_provider = None
|
|
srv.progress_interval = 30
|
|
srv.send_lock = threading.Lock()
|
|
srv.next_id = 1
|
|
srv.pending = {}
|
|
srv.pending_lock = threading.Lock()
|
|
srv.turn_queues = {}
|
|
srv.events = []
|
|
srv.events_lock = threading.Lock()
|
|
srv.restart_count = 0
|
|
srv.trace_path = None
|
|
srv._trace_lines = 0
|
|
return srv
|
|
|
|
|
|
class _FakeServer:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def initialize(self):
|
|
pass
|
|
|
|
def set_trace_file(self, *args, **kwargs):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def resume_thread(self, thread_id):
|
|
pass
|
|
|
|
def interrupt_turn(self, thread_id, turn_id):
|
|
return {"error": "x"}
|
|
|
|
|
|
class WaitTurnTestCase(unittest.TestCase):
|
|
def test_first_slice_timeout(self):
|
|
# 154: elapsed already past timeout before the first slice.
|
|
q = queue.Queue()
|
|
with mock.patch.object(
|
|
host.time, "monotonic",
|
|
side_effect=[100.0, 100.5]):
|
|
self.assertEqual(_wait_turn(q, 0.1), ("timeout", None))
|
|
|
|
|
|
class RunTurnInterruptRaiseTestCase(unittest.TestCase):
|
|
def test_interrupt_turn_raises(self):
|
|
# 777-780
|
|
srv = _server()
|
|
srv.request = mock.Mock(return_value={
|
|
"result": {"turn": {"id": "turn1"}}})
|
|
srv.interrupt_turn = mock.Mock(
|
|
side_effect=RuntimeError("interrupt down"))
|
|
with mock.patch.object(host, "_wait_turn",
|
|
return_value=("interrupted-requested",
|
|
None)):
|
|
out = srv.run_turn("th1", "do work")
|
|
self.assertEqual(out["status"], "interrupted")
|
|
|
|
|
|
class MainLeftoverTestCase(unittest.TestCase):
|
|
def _state_cfg(self, tmp, tasks):
|
|
state_path = os.path.join(tmp, "host-state.json")
|
|
cfg_path = os.path.join(tmp, "host-config.json")
|
|
with open(state_path, "w", encoding="utf-8") as f:
|
|
json.dump({"tasks": tasks, "automations": []}, f)
|
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
json.dump({}, f)
|
|
return state_path, cfg_path
|
|
|
|
def _run_main(self, argv, patches=()):
|
|
with contextlib.ExitStack() as stack:
|
|
for p in patches:
|
|
stack.enter_context(p)
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
with mock.patch.object(sys, "argv", argv):
|
|
host.main()
|
|
return buf.getvalue()
|
|
|
|
def test_daemon_stop_inner_break(self):
|
|
# 3407-3408: SIGTERM succeeds, the liveness probe then raises.
|
|
# (A real sleep child would stay a zombie until reaped, so the
|
|
# probe would succeed and the break would never fire; the
|
|
# kill calls are therefore faked for this branch.)
|
|
def _fake_kill(pid, sig):
|
|
if sig == 0:
|
|
raise ProcessLookupError("gone")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_path, cfg_path = self._state_cfg(tmp, {})
|
|
pidfile = os.path.join(tmp, "host-daemon.pid")
|
|
with open(pidfile, "w", encoding="utf-8") as f:
|
|
f.write("12345")
|
|
argv = ["host.py", "--state", state_path,
|
|
"--config", cfg_path, "daemon-stop"]
|
|
out = self._run_main(
|
|
argv, patches=[mock.patch("os.kill",
|
|
side_effect=_fake_kill)])
|
|
self.assertIn("daemon 已停", out)
|
|
self.assertFalse(os.path.exists(pidfile))
|
|
|
|
def test_daemon_subcommand_failure(self):
|
|
# 3504-3505
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_path, cfg_path = self._state_cfg(tmp, {})
|
|
argv = ["host.py", "--state", state_path,
|
|
"--config", cfg_path, "daemon", "--foreground",
|
|
"--interval", "300"]
|
|
old_term = signal.getsignal(signal.SIGTERM)
|
|
old_int = signal.getsignal(signal.SIGINT)
|
|
|
|
def sender():
|
|
time.sleep(0.5)
|
|
try:
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
except Exception:
|
|
pass
|
|
|
|
sender_thread = threading.Thread(target=sender, daemon=True)
|
|
try:
|
|
sender_thread.start()
|
|
out = self._run_main(argv, patches=[
|
|
mock.patch("subprocess.run",
|
|
side_effect=RuntimeError("child down")),
|
|
])
|
|
finally:
|
|
sender_thread.join(timeout=5)
|
|
signal.signal(signal.SIGTERM, old_term)
|
|
signal.signal(signal.SIGINT, old_int)
|
|
self.assertIn("daemon subcommand", out)
|
|
|
|
def test_interrupt_poll_responded(self):
|
|
# 3963-3964
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_path, cfg_path = self._state_cfg(tmp, {
|
|
"t1": {"name": "t1", "thread_id": "th1",
|
|
"status": "interrupted",
|
|
"turns": [{"result": {"turn_id": "turn9"}}]}})
|
|
argv = ["host.py", "--state", state_path,
|
|
"--config", cfg_path, "interrupt", "--task", "t1"]
|
|
out = self._run_main(argv, patches=[
|
|
mock.patch.object(host, "Server",
|
|
lambda **kw: _FakeServer()),
|
|
mock.patch("time.sleep", lambda *a, **k: None),
|
|
])
|
|
self.assertIn("属主已响应", out)
|
|
|
|
def test_interrupt_poll_read_failure(self):
|
|
# 3965-3967 (+ else branch): state reads fail during polling.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_path, cfg_path = self._state_cfg(tmp, {
|
|
"t1": {"name": "t1", "thread_id": "th1",
|
|
"status": "running",
|
|
"turns": [{"result": {"turn_id": "turn9"}}]}})
|
|
argv = ["host.py", "--state", state_path,
|
|
"--config", cfg_path, "interrupt", "--task", "t1"]
|
|
real_load = json.load
|
|
calls = {"n": 0}
|
|
|
|
def _load(fp, *a, **k):
|
|
calls["n"] += 1
|
|
if calls["n"] <= 2:
|
|
return real_load(fp, *a, **k)
|
|
raise RuntimeError("poll read down")
|
|
|
|
out = self._run_main(argv, patches=[
|
|
mock.patch.object(host, "Server",
|
|
lambda **kw: _FakeServer()),
|
|
mock.patch("time.sleep", lambda *a, **k: None),
|
|
mock.patch("json.load", side_effect=_load),
|
|
])
|
|
self.assertIn("10 秒内未响应", out)
|
|
|
|
def test_goal_verify_argv_warning(self):
|
|
# 4025
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_path, cfg_path = self._state_cfg(tmp, {})
|
|
argv = ["host.py", "--state", state_path,
|
|
"--config", cfg_path, "goal", "--name", "g",
|
|
"--task", "t", "--repo", "/tmp",
|
|
"--verify", "echo hi", "--verify-argv", "echo", "hi"]
|
|
out = self._run_main(argv, patches=[
|
|
mock.patch.object(host, "Server",
|
|
lambda **kw: _FakeServer()),
|
|
mock.patch.object(host, "dispatch",
|
|
return_value={"name": "g"}),
|
|
mock.patch.object(host, "run_goal_loop",
|
|
return_value=None),
|
|
])
|
|
self.assertIn("verify_argv 已指定", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|