diff --git a/herdr_hub/restart.py b/herdr_hub/restart.py index dfaa892..0612b39 100644 --- a/herdr_hub/restart.py +++ b/herdr_hub/restart.py @@ -32,6 +32,29 @@ def request(socket_path: str, method: str, params: dict, timeout: float = 10) -> return result +def ping_when_reachable(socket_path: str, *, timeout: float = 20) -> dict: + """Ping a server that may still be restarting right after an update. + + The native updater can return while the new server is still binding the + socket; a single refused connection must not be reported as a failed + update. The first attempt is immediate so a healthy server adds no delay. + """ + deadline: float | None = None + while True: + try: + return request(socket_path, "ping", {}, timeout=2) + except (OSError, ValueError, RuntimeError) as error: + now = time.monotonic() + if deadline is None: + deadline = now + timeout + elif now >= deadline: + raise RuntimeError( + f"更新后 {timeout:g} 秒内无法连接 Herdr 服务器:{error};" + "请检查 Session 状态,不会自动重启" + ) from error + time.sleep(.2) + + def installed_version(binary: str) -> str: version = subprocess.check_output([binary, "--version"], text=True, timeout=10).strip() if not version.startswith("herdr ") or not version[6:].strip(): @@ -62,7 +85,7 @@ def update_and_handoff(binary: str, session: str) -> None: # Let the native updater perform its opt-in handoff, scoped to this session. subprocess.run([binary, "--session", session, "update", "--handoff"], check=True, timeout=300) version = installed_version(binary) - current = request(socket_path, "ping", {}) + current = ping_when_reachable(socket_path) if current.get("version") != version: if binary_identity(binary) != identity_before: raise RuntimeError( diff --git a/tests/test_restart.py b/tests/test_restart.py index 6c5061e..70c7c38 100644 --- a/tests/test_restart.py +++ b/tests/test_restart.py @@ -65,6 +65,33 @@ class HandoffTests(unittest.TestCase): update_and_handoff("/fake/herdr", "named") self.assertEqual([c.args[1] for c in call.call_args_list].count("server.live_handoff"), 1) + def test_server_restarting_after_native_update_is_polled_not_failed(self): + old = {"version": "0.9.0", "capabilities": {"live_handoff": True}} + with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch( + "herdr_hub.restart.request", + side_effect=[old, ConnectionRefusedError(), FileNotFoundError(), {"version": "0.9.1"}], + ) as call, patch("herdr_hub.restart.subprocess.run"), patch( + "herdr_hub.restart.installed_version", return_value="0.9.1" + ), patch("herdr_hub.restart.time.sleep"), patch( + "herdr_hub.restart.time.monotonic", side_effect=[0, 0.2] + ), contextlib.redirect_stdout(io.StringIO()): + update_and_handoff("/fake/herdr", "named") + self.assertEqual([c.args[1] for c in call.call_args_list], ["ping"] * 4) + + def test_server_unreachable_after_update_is_reported_without_restart(self): + old = {"version": "0.9.0", "capabilities": {"live_handoff": True}} + with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch( + "herdr_hub.restart.request", + side_effect=[old, ConnectionRefusedError(), ConnectionRefusedError()], + ) as call, patch("herdr_hub.restart.subprocess.run"), patch( + "herdr_hub.restart.installed_version", return_value="0.9.1" + ), patch("herdr_hub.restart.time.sleep"), patch( + "herdr_hub.restart.time.monotonic", side_effect=[0, 25] + ), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(RuntimeError, "无法连接 Herdr 服务器"): + update_and_handoff("/fake/herdr", "named") + self.assertNotIn("server.live_handoff", [c.args[1] for c in call.call_args_list]) + def test_real_helper_updates_old_server_even_when_binary_already_latest(self): self._run_real_helper()