- 配置化: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>
211 lines
8.2 KiB
Python
211 lines
8.2 KiB
Python
# -*- 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 工作目录(与主程序同根目录部署)
|
||
)
|