diff --git a/host.py b/host.py index d5f6291..da2bc2f 100644 --- a/host.py +++ b/host.py @@ -2483,6 +2483,15 @@ def main(): args.progress_interval = 30 args.codex_home = args.codex_home or _resolve_path(cfg_dir, cfg.get("codex_home")) args.codex_bin = args.codex_bin or _resolve_path(cfg_dir, cfg.get("codex_bin")) + # daemon 循环的子进程要不要带 --state,就看这里:argparse 的 + # --state 默认是 None,未显式给时它就是 None;下面一行一旦 + # 解析完成就分不清“CLI 给的”还是“配置/默认解析出的”了。 + # 显式给的必须透传给 recover/run-due 子进程,否则子进程 + # 会回落到配置文件里的 state(或默认 state),与 daemon + # 本体(pidfile/logfile 都按 args.state 落盘)跑的不是同 + # 一个 state 文件。未显式给时不加 --state,子进程按原 + # 规则自行解析,结果与 daemon 本体一致,行为不变。 + explicit_state = args.state args.state = args.state or _resolve_path(cfg_dir, cfg.get("state")) or DEFAULT_STATE if args.cmd == "config": print(json.dumps({ @@ -2713,11 +2722,13 @@ def main(): while not stop["flag"]: ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) print(f"{ts} daemon tick: recover + run-due", flush=True) + child_base = [sys.executable, os.path.join(HERE, "host.py"), + "--config", config_path] + if explicit_state is not None: + child_base += ["--state", explicit_state] for subcmd in (["recover", "--older-than", "3600"], ["run-due"]): try: - subprocess.run([sys.executable, os.path.join(HERE, "host.py"), - "--config", config_path] + subcmd, - timeout=3600) + subprocess.run(child_base + subcmd, timeout=3600) except Exception as e: print(f"{ts} daemon subcommand {subcmd} 失败:{e}", flush=True) for _ in range(args.interval): diff --git a/tests/test_host_bugfix_host.py b/tests/test_host_bugfix_host.py index 1afcf67..65d28cf 100644 --- a/tests/test_host_bugfix_host.py +++ b/tests/test_host_bugfix_host.py @@ -153,5 +153,96 @@ class ConfigFileCloseTest(unittest.TestCase): 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()