Files
snowy-yang d71809d144 热重载监督者优雅响应 Ctrl+C:先等子进程收尾再回收
- 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 乱码
2026-09-24 21:55:17 +08:00

143 lines
5.4 KiB
Python
Raw Permalink 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
# 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)