The first ping after `herdr update --handoff` was sent exactly once. A server still binding its socket turned into "更新/热切换未完成" even though the update had succeeded. Retry connection errors for up to 20 seconds before giving up; a healthy server still answers immediately. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
121 lines
5.5 KiB
Python
121 lines
5.5 KiB
Python
"""Standalone POSIX update/handoff helper, also sent to remote Python over SSH."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import uuid
|
||
|
||
|
||
def request(socket_path: str, method: str, params: dict, timeout: float = 10) -> dict:
|
||
request_id = uuid.uuid4().hex
|
||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||
connection.settimeout(timeout)
|
||
connection.connect(socket_path)
|
||
connection.sendall((json.dumps({"id": request_id, "method": method, "params": params}) + "\n").encode())
|
||
with connection.makefile("rb") as stream:
|
||
raw = stream.readline(1024 * 1024)
|
||
if not raw.endswith(b"\n"):
|
||
raise RuntimeError("Herdr 控制响应不完整;请检查 Session 状态后再重试")
|
||
response = json.loads(raw)
|
||
if not isinstance(response, dict) or response.get("id") != request_id:
|
||
raise RuntimeError("Herdr 控制响应 ID 不匹配")
|
||
if response.get("error"):
|
||
raise RuntimeError(json.dumps(response["error"], ensure_ascii=False))
|
||
result = response.get("result")
|
||
if not isinstance(result, dict):
|
||
raise RuntimeError("Herdr 控制响应缺少结果")
|
||
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():
|
||
raise RuntimeError("无法识别已安装的 Herdr 版本:" + version)
|
||
return version[6:].strip()
|
||
|
||
|
||
def binary_identity(binary: str) -> tuple[int, int, int, int]:
|
||
info = os.stat(binary)
|
||
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns
|
||
|
||
|
||
def update_and_handoff(binary: str, session: str) -> None:
|
||
binary = str(Path(binary).absolute())
|
||
status = subprocess.check_output(
|
||
[binary, "--session", session, "status", "server"], text=True, timeout=10,
|
||
)
|
||
fields = dict(line.split(": ", 1) for line in status.splitlines() if ": " in line)
|
||
socket_path = fields.get("socket", "")
|
||
if fields.get("status") != "running" or not os.path.isabs(socket_path):
|
||
raise RuntimeError("当前 Session 没有可用的运行中服务器,不能执行热切换")
|
||
before = request(socket_path, "ping", {})
|
||
if before.get("capabilities", {}).get("live_handoff") is not True:
|
||
raise RuntimeError("当前服务器不支持热切换;可先使用“更新 Herdr”,再安排普通重启")
|
||
print(f"更新目标 Session:{session};当前服务器:{before.get('version', '未知')}", flush=True)
|
||
identity_before = binary_identity(binary)
|
||
# 0.8.x plain update refuses to complete with live sessions and no TTY.
|
||
# 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 = ping_when_reachable(socket_path)
|
||
if current.get("version") != version:
|
||
if binary_identity(binary) != identity_before:
|
||
raise RuntimeError(
|
||
f"已安装 {version},但原生热切换后服务器仍是 {current.get('version', '未知')};"
|
||
"不会重复发送热切换请求或强制重启,请检查上方原生命令输出"
|
||
)
|
||
print(f"正在热切换服务器到 {version},保留运行中的进程…", flush=True)
|
||
# An unchanged binary means the updater may have returned 'up to date'
|
||
# before touching an older running server. Only then send a handoff.
|
||
request(socket_path, "server.live_handoff", {
|
||
"import_exe": str(Path(binary).resolve()), "expected_version": version,
|
||
}, timeout=40)
|
||
deadline = time.monotonic() + 20
|
||
while True:
|
||
try:
|
||
current = request(socket_path, "ping", {}, timeout=2)
|
||
if current.get("version") == version:
|
||
break
|
||
except (OSError, ValueError, RuntimeError):
|
||
pass
|
||
if time.monotonic() >= deadline:
|
||
raise RuntimeError("热切换后未确认新服务器就绪;请检查状态,不会自动停止 Session")
|
||
time.sleep(.2)
|
||
print(f"服务器已确认使用 {version},可以重新连接。", flush=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
update_and_handoff(sys.argv[1], sys.argv[2])
|
||
except Exception as error:
|
||
print(f"更新/热切换未完成:{error}\n没有自动执行普通重启;请检查当前 Session。", file=sys.stderr, flush=True)
|
||
raise SystemExit(1)
|