Files
MusicBot/musicbot/reload.py
T
snowy-yang a11c0b375f 新增热重载:文件变更自动重启(开发用,默认关)
- musicbot/reload.py:轮询监控 musicbot/ 下的 .py 与 intents.toml/.env,
  变更后重启 bot 子进程;跳过 logs/、__pycache__/、.venv/
- 进程级重启而非模块重载:长连接 + 模块级状态 + handler 闭包,
  就地重载易留幽灵 handler,重启子进程状态干净
- 子进程用 _MUSICBOT_RELOAD_CHILD 标记,不会套娃自我复制
- Windows 用 taskkill /T 收整棵进程树,实测连续重载无进程泄漏
- 开启方式:`uv run musicbot --reload`(start.bat 已带)或 RELOAD=on
- 监督者不配日志,避免与子进程抢写同一日志文件
2026-09-20 22:44:20 +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
闭包,就地重载要清理 pyee 监听器与旧闭包,容易留下幽灵 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)