"""开发用热重载:监控源码与配置文件,变更后重启 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 # 监控的文件类型(源码与订阅/环境配置——两者都在启动时读取,改了需重启) _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) -> None: """结束子进程及其后代(bot 可能再拉起 Python 子进程)。""" if proc.poll() is not None: return 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) 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 finally: # 正常退出时是空操作;重启与 Ctrl-C 中断时回收子进程,避免孤儿 _terminate(proc)