- run_supervisor 捕获 KeyboardInterrupt:不打 traceback、退出码归零, 给子进程 _GRACE_SECONDS=5s 自行收尾窗口(打退出日志、关连接), 超时才 taskkill /F /T;文件变更重启仍立即强杀(子进程不知情不会自己退) - main() 兜底监督循环间隙的 Ctrl+C - start.bat 改为发射器:start 新开独立控制台跑 cmd /c 命令行(非批 处理),Ctrl+C 直接停进程,规避「终止批处理操作吗(Y/N)」,发射器 即退;bat 保持纯 ASCII 防 GBK 乱码
143 lines
5.4 KiB
Python
143 lines
5.4 KiB
Python
"""开发用热重载:监控源码与配置文件,变更后重启 bot 子进程。
|
||
|
||
进程级重启(而非进程内模块重载):bot 是长连接 + 模块级状态 + handler
|
||
闭包,就地重载要清理事件监听器与旧闭包,容易留下幽灵 handler;重启
|
||
子进程则状态与闭包都是干净的,代价是重连一次 websocket。
|
||
|
||
监督进程只负责拉起/杀掉子进程与轮询文件改动,自身不跑 bot;子进程由
|
||
`_MUSICBOT_RELOAD_CHILD` 环境变量标记,不会再套一层监督(`is_child`)。
|
||
"""
|
||
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from collections.abc import Iterable
|
||
from pathlib import Path
|
||
|
||
from loguru import logger
|
||
|
||
# 子进程标记:带上它启动的 musicbot 只跑 bot,不再做文件监督
|
||
CHILD_ENV = "_MUSICBOT_RELOAD_CHILD"
|
||
|
||
# 轮询间隔(秒):够快地感知保存动作,又不至于空耗 CPU
|
||
_POLL_INTERVAL = 1.0
|
||
|
||
# Ctrl+C 时给子进程的自行退出窗口(秒):子进程收到同一信号正在收尾
|
||
# (打退出日志、关连接),等它退完,超时才强杀
|
||
_GRACE_SECONDS = 5.0
|
||
|
||
# 监控的文件类型(源码与订阅/环境配置——两者都在启动时读取,改了需重启)
|
||
_WATCH_SUFFIXES = (".py", ".toml")
|
||
|
||
# 不监控的目录:日志、缓存与虚拟环境
|
||
_SKIP_DIRS = frozenset(
|
||
{".venv", "__pycache__", ".git", ".pytest_cache", ".ruff_cache", "logs"}
|
||
)
|
||
|
||
|
||
def is_child() -> bool:
|
||
"""当前进程是否是监督者拉起的 bot 子进程。"""
|
||
return os.environ.get(CHILD_ENV) == "1"
|
||
|
||
|
||
def iter_watched_files(root: Path) -> Iterable[Path]:
|
||
"""产出 root 下需要监控的文件:.py/.toml 源码配置,外加 .env。
|
||
|
||
`.env` 无后缀,按文件名单独匹配;跳过日志/缓存/虚拟环境目录。
|
||
"""
|
||
for path in sorted(root.rglob("*")):
|
||
if not path.is_file():
|
||
continue
|
||
if any(part in _SKIP_DIRS for part in path.parts):
|
||
continue
|
||
if path.suffix in _WATCH_SUFFIXES or path.name == ".env":
|
||
yield path
|
||
|
||
|
||
def snapshot(root: Path) -> dict[Path, float]:
|
||
"""取被监控文件的 (路径 → mtime) 快照;读不到时以 0 标记。"""
|
||
snap: dict[Path, float] = {}
|
||
for path in iter_watched_files(root):
|
||
try:
|
||
snap[path] = path.stat().st_mtime
|
||
except OSError:
|
||
snap[path] = 0.0
|
||
return snap
|
||
|
||
|
||
def changed_files(before: dict[Path, float], after: dict[Path, float]) -> list[Path]:
|
||
"""两次快照间的变更(新增/删除/修改)文件列表。"""
|
||
changed = {p for p in set(before) | set(after) if before.get(p) != after.get(p)}
|
||
return sorted(changed)
|
||
|
||
|
||
def _spawn(cmd: list[str], root: Path) -> subprocess.Popen:
|
||
return subprocess.Popen(cmd, cwd=root, env={**os.environ, CHILD_ENV: "1"})
|
||
|
||
|
||
def _terminate(proc: subprocess.Popen, grace: float = 0) -> None:
|
||
"""结束子进程及其后代(bot 可能再拉起 Python 子进程)。
|
||
|
||
grace > 0 时先等子进程自行退出,超时才强杀——用于 Ctrl+C 收尾场景;
|
||
文件变更重启时子进程并不知情、不会自己退,必须保持立即强杀。
|
||
"""
|
||
if proc.poll() is not None:
|
||
return
|
||
if grace:
|
||
try:
|
||
proc.wait(timeout=grace)
|
||
return
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning(f"子进程未在 {grace:g}s 内自行退出,强制结束")
|
||
if sys.platform == "win32":
|
||
# terminate() 只结束直接子进程,/T 连整棵进程树一起收掉
|
||
subprocess.run(
|
||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||
capture_output=True,
|
||
check=False,
|
||
)
|
||
else:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning("子进程未在 10s 内退出,强制结束")
|
||
proc.kill()
|
||
|
||
|
||
def run_supervisor(cmd: list[str], root: Path) -> int:
|
||
"""跑 bot 并在被监控文件变化时重启它,返回子进程退出码。
|
||
|
||
cmd 为子进程启动命令(无需 --reload,由 CHILD_ENV 标记取代)。
|
||
"""
|
||
watched = len(list(iter_watched_files(root)))
|
||
logger.info(f"热重载已开启,监控 {watched} 个文件(源码 + intents.toml/.env)")
|
||
|
||
while True:
|
||
logger.info("启动 bot 子进程")
|
||
proc = _spawn(cmd, root)
|
||
grace = 0.0
|
||
try:
|
||
before = snapshot(root)
|
||
while proc.poll() is None:
|
||
time.sleep(_POLL_INTERVAL)
|
||
after = snapshot(root)
|
||
touched = changed_files(before, after)
|
||
if touched:
|
||
names = ", ".join(str(p.relative_to(root)) for p in touched[:5])
|
||
tail = " 等" if len(touched) > 5 else ""
|
||
logger.info(f"检测到变更({names}{tail}),重启")
|
||
break
|
||
else:
|
||
# 子进程自行退出(含 Ctrl-C 透传):不重启,原样返回退出码
|
||
return proc.returncode or 0
|
||
except KeyboardInterrupt:
|
||
# Ctrl+C 同时送达子进程与此监督循环:吞掉异常不打印 traceback,
|
||
# 退出码归零,子进程的收尾交给下方带宽限的回收
|
||
grace = _GRACE_SECONDS
|
||
return 0
|
||
finally:
|
||
# 正常退出时是空操作;重启与 Ctrl-C 中断时回收子进程,避免孤儿
|
||
_terminate(proc, grace)
|