新增热重载:文件变更自动重启(开发用,默认关)

- 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
- 监督者不配日志,避免与子进程抢写同一日志文件
This commit is contained in:
snowy-yang committed 2026-09-20 22:44:20 +08:00
1 parent 9bcdad7aa1
commit a11c0b375f
8 files changed
+266 -4

No files matched your search

+4
View File
@@ -25,3 +25,7 @@ SEND_MODE=link
# 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天) # 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天)
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_DIR=logs LOG_DIR=logs
# 热重载(开发用):on 时监控源码与 intents.toml/.env,变更后自动重启 bot。
# 默认 off;也可用命令行 `musicbot --reload` 开启(start.bat 已带该参数)。
RELOAD=off
+3
View File
@@ -12,3 +12,6 @@ __pycache__/
# 编辑器 # 编辑器
.vscode/ .vscode/
.zcode/ .zcode/
# 研究/调试临时产物
_*.html
+6 -1
View File
@@ -11,6 +11,7 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两
- **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源 - **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源
- **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留 - **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留
- **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台 - **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台
- **热重载(开发用)**:监控 `musicbot/` 下的 `.py` 与 `intents.toml`/`.env`,变更后重启 bot 子进程(重连一次 websocket)。默认关,`uv run musicbot --reload`(`start.bat` 已带)或 `RELOAD=on` 开启
- 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启) - 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启)
## 配置 ## 配置
@@ -26,6 +27,7 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两
| `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) | | `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) |
| `LOG_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL | | `LOG_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL |
| `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) | | `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) |
| `RELOAD` | `off` | 热重载;`on` 等价于命令行 `--reload`(`start.bat` 已带) |
### 不 @ 机器人也能点歌 ### 不 @ 机器人也能点歌
@@ -38,9 +40,12 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两
```bash ```bash
uv sync uv sync
uv run musicbot uv run musicbot # 正常启动
uv run musicbot --reload # 开发:改源码/配置自动重启
``` ```
Windows 上双击 `start.bat` 即以热重载模式启动。
## 开发 ## 开发
```bash ```bash
uv run pytest uv run pytest
+2
View File
@@ -38,6 +38,7 @@ class BotConfig:
send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链 send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链
log_level: str = "INFO" # 控制台与文件日志级别 log_level: str = "INFO" # 控制台与文件日志级别
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台 log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
reload: bool = False # 热重载(开发用;--reload 亦可开启)
@classmethod @classmethod
def load(cls) -> "BotConfig": def load(cls) -> "BotConfig":
@@ -51,6 +52,7 @@ class BotConfig:
send_mode=_send_mode(), send_mode=_send_mode(),
log_level=_log_level(), log_level=_log_level(),
log_dir=Path(os.getenv("LOG_DIR", "logs")), log_dir=Path(os.getenv("LOG_DIR", "logs")),
reload=_flag("RELOAD", False),
) )
+33 -2
View File
@@ -1,12 +1,15 @@
"""装配入口:配置日志、注册命令并运行 bot。""" """装配入口:配置日志、注册命令并运行 bot(可选热重载)。"""
import argparse
import asyncio import asyncio
import sys import sys
from pathlib import Path
from aiohttp import ClientSession from aiohttp import ClientSession
from loguru import logger from loguru import logger
from qqbotsdk import EventEmitter, run_loop from qqbotsdk import EventEmitter, run_loop
from . import reload
from .config import BotConfig from .config import BotConfig
from .music import register from .music import register
@@ -52,7 +55,35 @@ async def amain() -> None:
await run_loop(ee) await run_loop(ee)
def main() -> None: def _parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="musicbot", description="QQ 群点歌 bot")
parser.add_argument(
"--reload",
action="store_true",
help="开发用:监控源码与配置,变更后自动重启(等价于 RELOAD=on)",
)
return parser.parse_args(argv)
def _project_root() -> Path:
"""项目根:包目录的上一级(flat 布局)。找不到 pyproject.toml 时退回当前
工作目录,避免安装成非 editable 包时监视到 site-packages。"""
root = Path(__file__).resolve().parent.parent
return root if (root / "pyproject.toml").is_file() else Path.cwd()
def main(argv: list[str] | None = None) -> None:
args = _parse_args(argv)
config = BotConfig.load()
# 子进程只跑 bot,绝不再套一层监督(否则 --reload 会无限自我复制)
reload_on = (args.reload or config.reload) and not reload.is_child()
if reload_on:
# 监控与子进程 cwd 都用项目根:intents.toml/.env 都在那里,
# 保证「监控的树」与「实际加载的配置」一致
root = _project_root()
# 监督者不配置日志:子进程自己会配,避免两边抢写同一个日志文件
code = reload.run_supervisor([sys.executable, "-m", "musicbot"], root)
sys.exit(code)
try: try:
asyncio.run(amain()) asyncio.run(amain())
except KeyboardInterrupt: except KeyboardInterrupt:
+122
View File
@@ -0,0 +1,122 @@
"""开发用热重载:监控源码与配置文件,变更后重启 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)
+1 -1
View File
@@ -1 +1 @@
uv run musicbot uv run musicbot --reload
+95
View File
@@ -663,6 +663,101 @@ def test_setup_logging_writes_file(tmp_path: Path):
lg.remove() lg.remove()
def test_config_reload_flag(monkeypatch):
"""RELOAD 默认关(生产不受影响),on 可开启,非法值按关处理。"""
from musicbot.config import BotConfig
monkeypatch.delenv("RELOAD", raising=False)
assert BotConfig.load().reload is False
monkeypatch.setenv("RELOAD", "on")
assert BotConfig.load().reload is True
monkeypatch.setenv("RELOAD", "随便写")
assert BotConfig.load().reload is False
# ---- 热重载:文件监听与进程监督 ----
def test_watched_files_and_skip_rules(tmp_path: Path):
"""监控 .py/.toml 与 .env;跳过 logs/、__pycache__/、.venv/ 与无关文件。"""
from musicbot import reload
(tmp_path / "musicbot").mkdir()
(tmp_path / "musicbot" / "music.py").write_text("", encoding="utf-8")
(tmp_path / "tests").mkdir()
(tmp_path / "tests" / "test_x.py").write_text("", encoding="utf-8")
(tmp_path / "intents.toml").write_text("", encoding="utf-8")
(tmp_path / ".env").write_text("", encoding="utf-8")
(tmp_path / "README.md").write_text("", encoding="utf-8") # 无关后缀
(tmp_path / "logs").mkdir()
(tmp_path / "logs" / "musicbot.log").write_text("", encoding="utf-8")
(tmp_path / "musicbot" / "__pycache__").mkdir()
(tmp_path / "musicbot" / "__pycache__" / "x.py").write_text("", encoding="utf-8")
watched = reload.iter_watched_files(tmp_path)
names = {p.relative_to(tmp_path).as_posix() for p in watched}
assert names == {"musicbot/music.py", "tests/test_x.py", "intents.toml", ".env"}
def test_changed_files_detects_edit_add_remove(tmp_path: Path):
"""快照比对:改动/新增/删除都能识别,未变则返回空。"""
from musicbot import reload
f = tmp_path / "musicbot"
f.mkdir()
mod = f / "music.py"
mod.write_text("a", encoding="utf-8")
before = reload.snapshot(tmp_path)
assert reload.changed_files(before, reload.snapshot(tmp_path)) == []
mod.write_text("bb", encoding="utf-8")
assert reload.changed_files(before, reload.snapshot(tmp_path)) == [mod]
def test_is_child_guard(monkeypatch):
"""子进程标记生效:带标记的进程不再做监督,避免 --reload 无限自我复制。"""
from musicbot import reload
monkeypatch.delenv(reload.CHILD_ENV, raising=False)
assert reload.is_child() is False
monkeypatch.setenv(reload.CHILD_ENV, "1")
assert reload.is_child() is True
def test_main_child_does_not_supervise(monkeypatch):
"""子进程即使带 --reload / RELOAD=on 也只跑 bot,不起监督
(否则 --reload 会无限自我复制)。"""
from musicbot import main as mb_main
from musicbot import reload
called: dict[str, bool] = {}
async def fake_amain() -> None:
called["bot"] = True
monkeypatch.setattr(reload, "is_child", lambda: True)
monkeypatch.setattr(mb_main, "amain", fake_amain)
monkeypatch.setattr(
reload, "run_supervisor",
lambda *a, **k: called.setdefault("supervisor", True),
)
monkeypatch.setattr(mb_main.BotConfig, "load", staticmethod(
lambda: replace(
BotConfig(
music_server_url="http://127.0.0.1:1",
music_quality="128k",
music_source="tx",
),
reload=True,
)
))
mb_main.main(["--reload"])
assert called == {"bot": True} # 跑的是 bot,没有起监督
# ---- 全量模式(不 @ 也能用)与去重 ---- # ---- 全量模式(不 @ 也能用)与去重 ----
def make_emitter(api, http, config) -> EventEmitter: def make_emitter(api, http, config) -> EventEmitter: