Files
codex-app-server/tests/test_host_bugfix_host.py
T
muse 45be478d70 fix(host): daemon 循环把显式 --state 透传给 recover/run-due 子进程
复核结论:应继承,不是原行为有意。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。
2026-11-03 02:49:25 +00:00

249 lines
9.7 KiB
Python
Raw 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.
"""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()