Files
herdr-session-switch/herdr_hub/restart.py
T
gitea-admin 6f933e1c80 Add Herdr update and live handoff menu actions
The Ctrl+A h command menu gains two host-scoped actions: a plain
`herdr update` that streams updater output into the popup, and an
experimental update-and-hot-restart flow that verifies a live server
handoff and reconnects the originating Hub tab once the popup closes.

Remote updates use the remote host's own proxy settings. A standalone
helper reads literal proxy assignments from shell profiles and running
Clash/Mihomo listeners without executing startup files; Windows reads
process, user and machine proxy variables through PowerShell.
2026-10-03 02:51:44 +00:00

98 lines
4.6 KiB
Python
Raw 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 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 = request(socket_path, "ping", {})
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)