Files
MusicBot/musicbot/reload.py
T
snowy-yang 6b6492dc8d 升级到新版 SDK 并补充点歌业务日志
- 锁定 qqbotsdk b37a53f(含:健康断开零等待重连、静默常规连接日志、
  失败才记断开与退避)
- 新增业务日志:启动打印生效配置、点歌/查歌词/帮助、菜单已发、
  选歌、按钮点击、语音/文件/直链已发,失败项统一带群号前缀
  (此前 18 处日志 17 处是 warning,成功路径完全静默,故日志里
  看不到任何点歌消息)
2026-09-21 18:26:23 +08:00

123 lines
4.5 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.
"""开发用热重载:监控源码与配置文件,变更后重启 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)