复核结论:应继承,不是原行为有意。daemon 本体的 pidfile/ logfile 按解析后 args.state 落盘,但旧循环只传 --config, 子进程回落到配置 state(或默认 state);CLI --state 与 配置 state 不同时,recover/run-due 跑的不是同一 state 文件。 修法:main() 解析前先记 explicit_state(argparse --state 默认 None,未给即 None);循环里仅当 explicit_state 非 None 时追加 --state,未显式给时不加、子进程按原规则解析, 与 daemon 本体一致、行为不变。daemonize() 本身只接 pidfile/logfile,不涉及 state,问题在 main() 循环里。 回归:DaemonStatePassthroughTest(2 项),旧代码上显式 项 fail、无显式项 pass,新代码上 2 项全 pass。
249 lines
9.7 KiB
Python
249 lines
9.7 KiB
Python
"""host.py 覆盖率 bugfix 轨(host 分工)回归测试。
|
||
|
||
只覆盖本分工的三项:_read_loop 非 dict 消息、配置/init 的
|
||
open() 关闭、daemon 循环的 --state 透传。不碰其它轨。
|
||
"""
|
||
import contextlib
|
||
import io
|
||
import json
|
||
import queue
|
||
import sys
|
||
import threading
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(REPO_ROOT))
|
||
|
||
import host
|
||
|
||
|
||
def _make_read_loop_server(lines, pending_id=7):
|
||
"""不拉起真实 app-server 的 Server:只给 _read_loop 供料。
|
||
|
||
proc.stdout 直接用字节行列表;_read_loop 对它做
|
||
`for raw in self.proc.stdout`,与真实 PIPE 的逐行迭代
|
||
语义一致。脏消息后仍能送达正常响应,即证明读循环
|
||
没有在脏消息处提前退出(旧代码会在第一条非 dict 处
|
||
抛 AttributeError/TypeError,被外层 except 吞掉后退出)。
|
||
"""
|
||
srv = host.Server.__new__(host.Server)
|
||
srv.approval = "deny"
|
||
srv.pending_lock = threading.Lock()
|
||
srv.pending = {pending_id: queue.Queue()}
|
||
srv.events_lock = threading.Lock()
|
||
srv.events = []
|
||
srv.turn_queues = {}
|
||
srv.trace_path = None
|
||
srv._trace_lines = 0
|
||
|
||
class FakeProc:
|
||
def __init__(self, stdout_lines):
|
||
self.stdout = stdout_lines
|
||
|
||
srv.proc = FakeProc(lines)
|
||
return srv
|
||
|
||
|
||
class ReadLoopNonDictTest(unittest.TestCase):
|
||
def test_non_dict_json_then_normal_response(self):
|
||
lines = [
|
||
b'""\n', # 合法 JSON,值是 str(旧代码致死)
|
||
b'"hello"\n', # 合法 JSON,值是 str
|
||
b'123\n', # 合法 JSON,值是 int
|
||
b'[1,2]\n', # 合法 JSON,值是 list
|
||
b'null\n', # 合法 JSON,值是 None
|
||
b'true\n', # 合法 JSON,值是 bool
|
||
b'not-json{\n', # 畸形 JSON:旧代码本已 continue
|
||
b'{"jsonrpc": "2.0", "id": 7, '
|
||
b'"result": {"ok": true}}\n',
|
||
]
|
||
srv = _make_read_loop_server(lines)
|
||
err = io.StringIO()
|
||
with contextlib.redirect_stderr(err):
|
||
srv._read_loop()
|
||
resp = srv.pending[7].get(timeout=1)
|
||
self.assertEqual(resp["result"], {"ok": True})
|
||
# 脏消息没有被当成通知塞进 events,也没有被当成请求处理。
|
||
self.assertEqual(srv.events, [])
|
||
self.assertIn("skip non-dict message", err.getvalue())
|
||
|
||
def test_non_dict_json_then_normal_notification(self):
|
||
lines = [
|
||
b'""\n',
|
||
b'{"jsonrpc": "2.0", "method": "thread/started", '
|
||
b'"params": {"threadId": "t1"}}\n',
|
||
]
|
||
srv = _make_read_loop_server(lines)
|
||
srv._read_loop()
|
||
self.assertEqual(len(srv.events), 1)
|
||
self.assertEqual(srv.events[0]["method"], "thread/started")
|
||
|
||
|
||
class ConfigFileCloseTest(unittest.TestCase):
|
||
"""第 2 项:_load_config_file 与 init 的 open() 必须关闭。
|
||
|
||
行号纠正:任务摘要的 2199/2443 是另一基线(coverage85
|
||
worktree)的行号;origin/main aebc623 上两处分别是
|
||
_load_config_file 的 json.load(open(path)) 与 main() init
|
||
分支的 json.dump(init_cfg, open(config_path, "w")),
|
||
2443 行在 aebc623 上是 `if not args.repo:`,并无 open。
|
||
本测试只针对这两处生产路径,不扩大到其它裸 open()。
|
||
"""
|
||
|
||
def test_load_config_file_no_resource_warning(self):
|
||
import gc
|
||
import os
|
||
import tempfile
|
||
import warnings
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
cfg_path = os.path.join(tmp, "cfg.json")
|
||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||
json.dump({"model": "x"}, f)
|
||
with warnings.catch_warnings(record=True) as caught:
|
||
warnings.simplefilter("always", ResourceWarning)
|
||
value = host._load_config_file(cfg_path)
|
||
gc.collect()
|
||
self.assertEqual(value, {"model": "x"})
|
||
self.assertEqual(
|
||
[str(w.message) for w in caught
|
||
if issubclass(w.category, ResourceWarning)], [])
|
||
|
||
def test_load_config_file_bad_json_still_raises_and_closes(self):
|
||
import gc
|
||
import os
|
||
import tempfile
|
||
import warnings
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
cfg_path = os.path.join(tmp, "cfg.json")
|
||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||
f.write("{not json")
|
||
with warnings.catch_warnings(record=True) as caught:
|
||
warnings.simplefilter("always", ResourceWarning)
|
||
with self.assertRaises(RuntimeError):
|
||
host._load_config_file(cfg_path)
|
||
gc.collect()
|
||
self.assertEqual(
|
||
[str(w.message) for w in caught
|
||
if issubclass(w.category, ResourceWarning)], [])
|
||
|
||
def test_init_writes_config_no_resource_warning(self):
|
||
import gc
|
||
import os
|
||
import tempfile
|
||
import warnings
|
||
from unittest import mock
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
cfg_path = os.path.join(tmp, "new-config.json")
|
||
argv = ["host.py", "--config", cfg_path, "init"]
|
||
out = io.StringIO()
|
||
with warnings.catch_warnings(record=True) as caught:
|
||
warnings.simplefilter("always", ResourceWarning)
|
||
with mock.patch.object(sys, "argv", argv):
|
||
with contextlib.redirect_stdout(out):
|
||
host.main()
|
||
gc.collect()
|
||
with open(cfg_path, encoding="utf-8") as f:
|
||
written = json.load(f)
|
||
self.assertEqual(written["state"], "host-state.json")
|
||
self.assertEqual(written["model"], "gpt-5.6-terra")
|
||
self.assertIn("配置文件已写", out.getvalue())
|
||
self.assertEqual(
|
||
[str(w.message) for w in caught
|
||
if issubclass(w.category, ResourceWarning)], [])
|
||
|
||
|
||
class DaemonStatePassthroughTest(unittest.TestCase):
|
||
"""第 3 项:daemon 循环的子进程是否继承显式 --state。
|
||
|
||
复核结论:应继承。daemon 本体的 pidfile/logfile 都按
|
||
解析后的 args.state 落盘,但旧循环只给子进程传 --config,
|
||
子进程会回落到配置文件里的 state(或默认 state);当
|
||
CLI --state 与配置 state 不同时,recover/run-due 跑的
|
||
就不是 daemon 本体那个 state 文件。daemonize() 本身只
|
||
接 pidfile/logfile,不涉及 state,问题在 main() 的循环里。
|
||
|
||
测试直接跑 main() 的 daemon --foreground 循环:子进程
|
||
调用被 mock 捕获(不真拉 host.py 子进程),循环由定时
|
||
发给本进程的 SIGTERM 停下(daemon 的 handler 只置 flag)。
|
||
"""
|
||
|
||
def _run_daemon_once(self, argv):
|
||
import os
|
||
import signal
|
||
import time
|
||
from unittest import mock
|
||
|
||
calls = []
|
||
|
||
def fake_run(cmd, **kw):
|
||
calls.append(list(cmd))
|
||
return mock.Mock(returncode=0)
|
||
|
||
def sender():
|
||
time.sleep(1.5)
|
||
try:
|
||
os.kill(os.getpid(), signal.SIGTERM)
|
||
except Exception:
|
||
pass
|
||
|
||
old_term = signal.getsignal(signal.SIGTERM)
|
||
old_int = signal.getsignal(signal.SIGINT)
|
||
sender_thread = threading.Thread(target=sender, daemon=True)
|
||
out = io.StringIO()
|
||
try:
|
||
sender_thread.start()
|
||
with mock.patch.object(sys, "argv", argv):
|
||
with mock.patch("subprocess.run",
|
||
side_effect=fake_run):
|
||
with contextlib.redirect_stdout(out):
|
||
host.main()
|
||
finally:
|
||
sender_thread.join(timeout=5)
|
||
signal.signal(signal.SIGTERM, old_term)
|
||
signal.signal(signal.SIGINT, old_int)
|
||
return calls, out.getvalue()
|
||
|
||
def test_explicit_state_passed_to_children(self):
|
||
import os
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
cfg_path = os.path.join(tmp, "cfg.json")
|
||
cli_state = os.path.join(tmp, "cli-state.json")
|
||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||
json.dump({"state": "config-state.json"}, f)
|
||
argv = ["host.py", "--config", cfg_path, "--state", cli_state,
|
||
"daemon", "--foreground", "--interval", "60"]
|
||
calls, out = self._run_daemon_once(argv)
|
||
self.assertEqual(len(calls), 2)
|
||
self.assertIn(f"state={cli_state}", out)
|
||
for call in calls:
|
||
self.assertEqual(call[1], str(REPO_ROOT / "host.py"))
|
||
self.assertIn("--config", call)
|
||
self.assertIn(cfg_path, call)
|
||
self.assertIn("--state", call)
|
||
self.assertIn(cli_state, call)
|
||
self.assertNotIn("config-state.json", " ".join(call))
|
||
self.assertEqual(calls[0][-3:],
|
||
["recover", "--older-than", "3600"])
|
||
self.assertEqual(calls[1][-1], "run-due")
|
||
|
||
def test_no_explicit_state_children_unchanged(self):
|
||
import os
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
cfg_path = os.path.join(tmp, "cfg.json")
|
||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||
json.dump({"state": "config-state.json"}, f)
|
||
argv = ["host.py", "--config", cfg_path,
|
||
"daemon", "--foreground", "--interval", "60"]
|
||
calls, out = self._run_daemon_once(argv)
|
||
self.assertEqual(len(calls), 2)
|
||
self.assertIn("config-state.json", out)
|
||
for call in calls:
|
||
self.assertNotIn("--state", call)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|