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。
This commit is contained in:
1 parent
1156d3c754
commit
45be478d70
2 files changed
+105
-3
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user