From 03771df508dee8770116b00fbb2f249d5512c0ef Mon Sep 17 00:00:00 2001 From: m4 Date: Wed, 12 Aug 2026 16:30:49 +0800 Subject: [PATCH] feat(update): standalone updater runner (wait/install/restart/result) --- EvoScientist/updater.py | 109 ++++++++++++++++++++++++++++++++++- tests/test_updater_runner.py | 97 +++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/test_updater_runner.py diff --git a/EvoScientist/updater.py b/EvoScientist/updater.py index 8b7dc11..c4b7a01 100644 --- a/EvoScientist/updater.py +++ b/EvoScientist/updater.py @@ -10,7 +10,6 @@ from __future__ import annotations import json import os import shutil -import signal import subprocess import sys import time @@ -187,3 +186,111 @@ def spawn_updater(updater_copy: Path, plan_path: Path, parent_pid: int) -> subpr stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, ) + + +# --------------------------------------------------------------------------- +# Standalone runner (executed from the copied updater.py) +# --------------------------------------------------------------------------- + + +def _pid_alive_default(pid: int) -> bool: + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def wait_for_pid_exit(pid, timeout, *, poll=0.5, sleep=time.sleep, pid_alive=None): + alive = pid_alive or _pid_alive_default + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not alive(pid): + return True + sleep(poll) + return not alive(pid) + + +def _append_log(log_path: Path, text: str) -> None: + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(text) + + +def _write_result(plan: dict, staging: Path, status: str, stage: str | None, message: str) -> None: + payload = { + "status": status, + "stage": stage, + "version": plan["version"], + "previous_version": plan.get("previous_version", ""), + "message": message, + "log": plan["log_path"], + } + result_path = Path(plan["result_path"]) + result_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + # fixed read location for the status endpoint + (staging / "last-result.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def run_plan(plan_path, *, parent_pid, sleep=time.sleep, pid_alive=None, run=subprocess.run, popen=subprocess.Popen): + plan_path = Path(plan_path) + plan = json.loads(plan_path.read_text(encoding="utf-8")) + staging = Path(plan["result_path"]).parent.parent # /v/update-result.json + log_path = Path(plan["log_path"]) + _append_log(log_path, f"updater start: pid={os.getpid()} parent={parent_pid}\n") + try: + if not wait_for_pid_exit(parent_pid, PID_WAIT_TIMEOUT, sleep=sleep, pid_alive=pid_alive): + _write_result(plan, staging, "failed", "wait", f"parent {parent_pid} still alive after {PID_WAIT_TIMEOUT}s") + return 1 + + proc = run(plan["install_command"], capture_output=True, text=True, cwd=plan["cwd"]) + _append_log(log_path, f"install rc={proc.returncode}\n{proc.stdout}\n{proc.stderr}\n") + if proc.returncode != 0: + _write_result(plan, staging, "failed", "install", proc.stderr.strip() or "install failed") + return 1 + + strategy = plan["restart"]["strategy"] + if strategy == "cli": + # The respawned service is long-lived, so we cannot judge success by + # waiting on it; only a spawn failure (missing binary etc.) counts + # as a restart failure. + try: + popen( + plan["respawn_command"], + cwd=plan["cwd"], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + ) + except OSError as exc: + _write_result(plan, staging, "installed_restart_failed", "restart", str(exc)) + return 1 + elif strategy == "systemd": + unit = plan["restart"]["unit"] + proc = run(["systemctl", "--user", "restart", unit], capture_output=True, text=True) + _append_log(log_path, f"systemctl rc={proc.returncode}\n{proc.stdout}\n{proc.stderr}\n") + if proc.returncode != 0: + _write_result( + plan, staging, "installed_restart_failed", "restart", + f"systemctl --user restart {unit} failed; run it manually", + ) + return 1 + + _write_result(plan, staging, "success", None, "update applied") + return 0 + finally: + release_plan_lock(plan_path) + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser(prog="EvoScientist.updater") + parser.add_argument("--plan", required=True) + parser.add_argument("--parent-pid", required=True, type=int) + args = parser.parse_args(argv) + return run_plan(Path(args.plan), parent_pid=args.parent_pid) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_updater_runner.py b/tests/test_updater_runner.py new file mode 100644 index 0000000..d584003 --- /dev/null +++ b/tests/test_updater_runner.py @@ -0,0 +1,97 @@ +"""Tests for the standalone updater runner (run_plan / wait_for_pid_exit).""" + +import json +import os +import sys +from pathlib import Path + +from EvoScientist.updater import run_plan, wait_for_pid_exit + + +def test_wait_for_pid_exit_true_when_gone(): + alive = {"n": 3} + + def pid_alive(pid): + alive["n"] -= 1 + return alive["n"] > 0 + + assert wait_for_pid_exit(1234, timeout=10, poll=0, sleep=lambda s: None, pid_alive=pid_alive) + + +def test_wait_for_pid_exit_timeout(): + assert not wait_for_pid_exit(1234, timeout=0.01, poll=0, sleep=lambda s: None, pid_alive=lambda p: True) + + +def _write_plan(tmp_path, **over): + target = tmp_path / "v1" + target.mkdir() + plan = { + "version": "1.0.0", + "artifact": str(target / "pkg.whl"), + "install_command": ["true"], + "restart": {"strategy": "cli", "unit": None}, + "respawn_command": ["echo", "restarted"], + "cwd": str(tmp_path), + "deploy_mode": "full", + "previous_version": "0.9.9", + "result_path": str(target / "update-result.json"), + "log_path": str(target / "updater.log"), + } + plan.update(over) + plan_path = target / "plan.json" + plan_path.write_text(json.dumps(plan), encoding="utf-8") + return plan_path + + +def test_run_plan_success(tmp_path): + plan_path = _write_plan(tmp_path) + rc = run_plan(plan_path, parent_pid=os.getpid(), pid_alive=lambda p: False) + assert rc == 0 + result = json.loads(Path(json.loads(plan_path.read_text())["result_path"]).read_text()) + assert result["status"] == "success" + assert result["version"] == "1.0.0" + last = json.loads((tmp_path / "last-result.json").read_text()) + assert last["status"] == "success" + + +def test_run_plan_install_failure(tmp_path): + plan_path = _write_plan( + tmp_path, + install_command=[sys.executable, "-c", "import sys; sys.exit(3)"], + ) + rc = run_plan(plan_path, parent_pid=os.getpid(), pid_alive=lambda p: False) + assert rc == 1 + result = json.loads(Path(json.loads(plan_path.read_text())["result_path"]).read_text()) + assert result["status"] == "failed" + assert result["stage"] == "install" + + +def test_run_plan_restart_failure(tmp_path): + plan_path = _write_plan(tmp_path, respawn_command=["/nonexistent/evosci-binary"]) + rc = run_plan(plan_path, parent_pid=os.getpid(), pid_alive=lambda p: False) + assert rc == 1 + result = json.loads(Path(json.loads(plan_path.read_text())["result_path"]).read_text()) + assert result["status"] == "installed_restart_failed" + + +def test_run_plan_releases_plan_lock(tmp_path): + plan_path = _write_plan(tmp_path) + lock = plan_path.with_suffix(".lock") + lock.write_text("") + run_plan(plan_path, parent_pid=os.getpid(), pid_alive=lambda p: False) + assert not lock.exists() + + +def test_run_plan_parent_never_exits(tmp_path, monkeypatch): + monkeypatch.setattr("EvoScientist.updater.PID_WAIT_TIMEOUT", 0.01) + plan_path = _write_plan(tmp_path) + rc = run_plan( + plan_path, + parent_pid=os.getpid(), + sleep=lambda s: None, + pid_alive=lambda p: True, + ) + assert rc == 1 + result = json.loads(Path(json.loads(plan_path.read_text())["result_path"]).read_text()) + assert result["status"] == "failed" + assert result["stage"] == "wait"