Files
hospital-front-agent/agent/process_guard.py
T
lianlonggangandClaude 0b13de14eb feat: 升级流程增强 + 网关化配置 + 重连循环化
- 配置化:gateway.url/socket.path/download.prefix(网关基础地址统一推导 ws 与下载地址)、
  main.home 主目录(jar/pid 相对它推导)、order.basic;properties 中文 \uXXXX 转义自动还原
- 升级流程:下载 PK 魔数检查与诊断日志;SHA 校验;替换改「改名腾位」绕开 Windows 文件锁延迟;
  nssm stop/start 驱动停止与拉起(pid 强杀回退);升级中标志暂停看门狗拉起避免抢占 jar 锁
- ws_client:重连改循环实现修复递归栈溢出(长时间断线后 maximum recursion depth);
  退避封顶延长至 5 分钟;心跳线程独立修复「暂无心跳数据」
- 新增命令:GET_APIS/HTTP_PROXY(接口测试代理,纯透传鉴权)、GET_LOG_DOWNLOAD(日志下载备用)
- 诊断增强:taskkill/进程退出/替换重试逐次日志

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-03 16:52:34 +08:00

211 lines
8.2 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.
# -*- coding: utf-8 -*-
"""主程序进程守护(看门狗)。
周期探测主程序健康:
- 连续 3 次失败 → 认为主程序宕机 → 强杀残留 → 调启动命令拉起
- 拉起后等待健康恢复;连续 3 次拉起失败 → 上报告警(ALERT)
"""
import logging
import subprocess
import threading
log = logging.getLogger(__name__)
# 模块级「升级中」标志:升级流程开始时置 True,结束后置 False。
# 看门狗探测到主程序不健康时若此标志为 True,说明是升级主动停的,跳过拉起,
# 避免升级替换 jar 时被看门狗拉起的新进程抢占文件锁。
_upgrading = threading.Event()
class ProcessGuard:
"""看门狗:周期健康探测 + 宕机拉起 + 告警上报。"""
def __init__(self, config, main_app_client, client):
self.config = config
self.main_app_client = main_app_client
self.client = client
self._fail_count = 0 # 连续健康探测失败次数
self._restart_count = 0 # 连续拉起次数
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self):
"""启动守护(首检延迟 30 秒,之后每 15 秒探测一次)。"""
if not self.config.guard_enabled:
log.info("进程守护未启用(guard.enable=false")
return
self._thread = threading.Thread(target=self._loop, name="process-guard", daemon=True)
self._thread.start()
log.info("进程守护已启动:每 15 秒探测 %s", self.config.main_health_url)
def stop(self):
"""停止守护。"""
self._stop.set()
# ================= 探测循环 =================
def _loop(self):
"""探测主循环:先等 30 秒(给主程序启动时间),再周期探测。"""
if self._stop.wait(30):
return
while not self._stop.wait(15):
try:
self._check()
except Exception as e:
log.warning("守护探测异常:%s", e)
def _check(self):
"""单次探测:健康则清零计数;连续 3 次失败则拉起(升级期间跳过)。"""
health = self.main_app_client.health()
if health.get("ok") is True:
self._fail_count = 0
return
# 升级流程主动停的主程序,看门狗不接管,避免抢占 jar 锁
if _upgrading.is_set():
log.info("主程序不可达,但升级流程进行中,看门狗跳过拉起")
self._fail_count = 0 # 不累计,升级结束后自然恢复
return
self._fail_count += 1
log.warning("主程序健康探测失败(%d/3):%s", self._fail_count, health.get("error"))
if self._fail_count < 3:
return
# 连续 3 次失败 → 拉起
self._fail_count = 0
self._restart_count += 1
attempts = self._restart_count
if attempts > 3:
self.client.send("ALERT", {
"level": "CRITICAL",
"msg": f"主程序连续 {attempts} 次拉起失败,请人工介入",
})
# 重置计数,之后继续尝试(降频可后续优化)
self._restart_count = 3
return
# 拉起方式:配置了服务名则走 nssm restart(幂等),否则回退启动命令
if self.config.main_service_name:
start_desc = f"nssm restart {self.config.main_service_name}"
else:
start_desc = self.config.main_start_cmd
log.error("主程序宕机,执行第 %d 次拉起:%s", attempts, start_desc)
self.client.send("ALERT", {
"level": "WARN",
"msg": f"主程序宕机,Agent 正在执行第 {attempts} 次拉起",
})
try:
if self.config.main_service_name:
ok, reason = restart_main_service(self.config.nssm_path, self.config.main_service_name)
if not ok:
log.error("nssm 拉起失败:%s", reason)
else:
proc = start_main_app(self.config.main_start_cmd)
log.info("启动命令已执行,pid=%s", proc.pid if proc else None)
except Exception as e:
log.error("拉起失败:%s", e)
# ================= 公共工具(upgrade.py 复用) =================
def restart_main_service(nssm_path: str, service_name: str) -> tuple[bool, str]:
"""
通过 NSSM 重启主程序服务(幂等:无论服务处于停止/运行态均到运行态)。
nssm_path 为 nssm.exe 完整路径;service_name 为注册的 Windows 服务名。
返回 (是否成功, 输出/原因)。nssm 输出打印到日志,权限不足(拒绝访问)等
失败原因一眼可见。
"""
try:
proc = subprocess.run(
[nssm_path, "restart", service_name],
capture_output=True,
timeout=60,
)
# nssm 输出为 UTF-16 编码(宽字符),需按 utf-16 解码
out = ((proc.stdout or b"") + (proc.stderr or b"")).decode("utf-16", errors="replace").strip()
ok = proc.returncode == 0
if ok:
log.info("nssm restart %s 成功:%s", service_name, out or "(无输出)")
else:
log.warning("nssm restart %s 失败(exit=%s):%s", service_name, proc.returncode, out)
if "拒绝访问" in out or "Access" in out:
out += "(提示:重启服务需要管理员权限,请将 Agent 以 Windows 服务方式运行)"
return ok, out
except Exception as e:
log.warning("nssm restart %s 执行异常:%s", service_name, e)
return False, str(e)
def stop_main_service(nssm_path: str, service_name: str) -> tuple[bool, str]:
"""
通过 NSSM 停止主程序服务(nssm stop 会等待服务进程退出后返回)。
用于升级流程:停止成功即文件锁释放,替换 jar 不再被 WinError 32 挡住。
nssm_path 为 nssm.exe 完整路径;service_name 为注册的 Windows 服务名。
返回 (是否成功, 输出/原因)。
"""
try:
proc = subprocess.run(
[nssm_path, "stop", service_name],
capture_output=True,
timeout=120,
)
out = ((proc.stdout or b"") + (proc.stderr or b"")).decode("utf-16", errors="replace").strip()
ok = proc.returncode == 0
if ok:
log.info("nssm stop %s 成功:%s", service_name, out or "(无输出)")
else:
log.warning("nssm stop %s 失败(exit=%s):%s", service_name, proc.returncode, out)
return ok, out
except Exception as e:
log.warning("nssm stop %s 执行异常:%s", service_name, e)
return False, str(e)
def start_main_service(nssm_path: str, service_name: str) -> tuple[bool, str]:
"""
通过 NSSM 启动主程序服务(nssm start 触发服务进入运行态,应用初始化由健康检查轮询确认)。
返回 (是否成功, 输出/原因)。
"""
try:
proc = subprocess.run(
[nssm_path, "start", service_name],
capture_output=True,
timeout=60,
)
out = ((proc.stdout or b"") + (proc.stderr or b"")).decode("utf-16", errors="replace").strip()
ok = proc.returncode == 0
if ok:
log.info("nssm start %s 成功:%s", service_name, out or "(无输出)")
else:
log.warning("nssm start %s 失败(exit=%s):%s", service_name, proc.returncode, out)
return ok, out
except Exception as e:
log.warning("nssm start %s 执行异常:%s", service_name, e)
return False, str(e)
def start_main_app(start_cmd: str) -> subprocess.Popen | None:
"""
拉起主程序。
Windows 下 .bat 走 cmd /c(路径转反斜杠);其余走 sh。
返回 Popen 对象(不等待进程结束)。
"""
cmd = start_cmd.replace("/", "\\") # cmd/c 要求反斜杠路径
if cmd.lower().endswith(".bat"):
args = ["cmd", "/c", cmd, "start"]
else:
args = ["sh", start_cmd, "start"]
return subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
# 不设 cwd,继承 Agent 工作目录(与主程序同根目录部署)
)