Files
gitea-adminandClaude Fable 5.1 83dc7b5ef9 Tolerate a restarting server after the native update
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>
2026-10-03 03:00:09 +00:00

121 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)