From 47cb288e690ca2e275d93ae6bd9212e370b4b59b Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:33:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20reloader=20=E5=B9=B6=E5=85=A5=20aut?= =?UTF-8?q?o=5Fupdate=20=E8=87=AA=E5=8A=A8=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 16 ++-- src/plugins/auto_update/__init__.py | 113 ++++++++++++++++++++++++++-- src/plugins/auto_update/reloader.py | 86 --------------------- 3 files changed, 111 insertions(+), 104 deletions(-) delete mode 100644 src/plugins/auto_update/reloader.py diff --git a/bot.py b/bot.py index feff842..13b5080 100644 --- a/bot.py +++ b/bot.py @@ -1,19 +1,15 @@ -"""机器人入口 - -两种启动方式: -- 开发调试:nb run --reload (文件变化自动重载,不走本入口) -- 正式运行:python bot.py (监督进程模式,auto_update 插件拉取更新后可优雅重启) -""" - import nonebot +from nonebot.adapters.onebot.v11 import Adapter as ONEBOT_V11Adapter nonebot.init() -nonebot.load_from_toml("pyproject.toml") # 适配器 + 第三方插件 + 本地插件目录 +driver = nonebot.get_driver() +driver.register_adapter(ONEBOT_V11Adapter) -from src.plugins.auto_update.reloader import install_reloader +nonebot.load_builtin_plugins("echo") -install_reloader() + +nonebot.load_from_toml("pyproject.toml") if __name__ == "__main__": nonebot.run() diff --git a/src/plugins/auto_update/__init__.py b/src/plugins/auto_update/__init__.py index 7f13a37..19c2f2d 100644 --- a/src/plugins/auto_update/__init__.py +++ b/src/plugins/auto_update/__init__.py @@ -2,18 +2,33 @@ 定时从远程仓库拉取更新(fetch + 仅快进合并,绝不触碰本地改动), 本地插件代码随之更新;若 uv.lock / pyproject.toml 有变化则执行 -uv sync 同步第三方插件依赖。更新后可向指定群发通知, -可选自动退出进程(配合进程守护工具实现重启加载新代码)。 +uv sync 同步第三方插件依赖。更新后可向指定群发通知并自动重启。 + +重启方式按启动环境自动选择: +- 默认入口 bot.py 直接启动:自动启用监督进程模式(multiprocessing), + 拉取完整结束后触发干净重启,不会像 --reload 那样在拉取中途 + 触发重载加载到残缺代码,无需修改入口文件、无需进程守护工具 +- nb run --reload:不干预,文件变化由 uvicorn/watchfiles 自动重载 +- 其他部署方式:可配置 AUTO_UPDATE_RESTART 退出进程交给守护工具拉起 """ import asyncio import os import shutil import sys +import threading +from multiprocessing import get_context from pathlib import Path +from typing import Any +import nonebot from nonebot import get_bots, get_driver, get_plugin_config, logger, require -from nonebot.adapters.onebot.v11 import ActionFailed, Bot, Message, NetworkError +from nonebot.adapters.onebot.v11 import ( + ActionFailed, + Bot, + Message, + NetworkError, +) from nonebot.plugin import PluginMetadata require("nonebot_plugin_apscheduler") @@ -21,7 +36,6 @@ require("nonebot_plugin_apscheduler") from nonebot_plugin_apscheduler import scheduler from .config import Config -from .reloader import Reloader __plugin_meta__ = PluginMetadata( name="git自动更新", @@ -42,7 +56,7 @@ _HAS_GIT = shutil.which("git") is not None _HAS_UV = shutil.which("uv") is not None # 以 --reload 模式运行时(如 nb run --reload),uvicorn/watchfiles 监听到 -# 文件变化会自动重启 worker 加载新代码,插件无需(也不应)自己退出进程 +# 文件变化会自动重启 worker 加载新代码,插件无需(也不应)自己重启进程 _RELOAD_MODE = "--reload" in sys.argv if not (PROJECT_ROOT / ".git").exists(): @@ -56,6 +70,88 @@ if not _HAS_UV: _ENV = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} +# ------------------------ 监督进程重启器 ------------------------ + +_nb_run = nonebot.run + + +class ReloaderNotEnabledError(RuntimeError): + """监督进程模式未启用(非默认入口启动时不接管 nonebot.run)""" + + +class Reloader: + """跨进程重启开关:机器人进程中调用 reload() 请求父进程重启自己""" + + # 实际对象为 multiprocessing.Event(与 threading.Event 接口一致) + event: threading.Event | None = None + + @classmethod + def reload(cls, delay: int = 0) -> None: + """请求重启机器人进程 + + 参数: + delay: 延迟秒数(给群通知等收尾工作留出发送时间) + """ + if cls.event is None: + raise ReloaderNotEnabledError + if delay > 0: + threading.Timer(delay, cls.event.set).start() + return + cls.event.set() + + +def _bot_process(ev: Any, *args: Any, **kwargs: Any) -> None: + """监督子进程入口:真正的机器人在这里运行""" + Reloader.event = ev + _nb_run(*args, **kwargs) + + +def _supervised_run(*args: Any, **kwargs: Any) -> None: + """监督进程入口:循环拉起机器人子进程,直到其自行退出或收到重启事件""" + should_exit = False + ctx = get_context("spawn") + while not should_exit: + event = ctx.Event() + process = ctx.Process( + target=_bot_process, args=(event, *args), kwargs=kwargs + ) + process.start() + while not should_exit: + if event.wait(1): + logger.info("[auto_update] 收到重启事件,正在重启机器人进程") + process.terminate() + process.join(config.auto_update_reboot_grace_time) + if process.is_alive(): + logger.warning( + "[auto_update] 进程未在 " + f"{config.auto_update_reboot_grace_time} " + "秒内优雅退出,强制结束" + ) + process.kill() + break + if not process.is_alive(): + # 子进程自行退出(如 Ctrl+C),监督进程随之结束 + should_exit = True + + +def _install_reloader() -> None: + """从默认入口 bot.py 直接启动时自动接管 nonebot.run""" + if _RELOAD_MODE: + return + main_module = sys.modules.get("__main__") + main_file = str(getattr(main_module, "__file__", "") or "") + if Path(main_file).name != "bot.py": + return + nonebot.run = _supervised_run + logger.info("[auto_update] 已启用监督进程模式(更新拉取完整后自动优雅重启)") + + +_install_reloader() + + +# ------------------------ 更新检查 ------------------------ + + async def _run(cmd: list[str], cwd: Path, timeout: int = 120) -> tuple[bool, str]: """运行命令,返回 (是否成功, 合并了 stderr 的输出)""" try: @@ -155,8 +251,9 @@ async def _check_and_update() -> None: ) return dep_note = await _sync_dependencies() - if Reloader.event is not None: - # 监督进程模式(python bot.py):拉取完整结束后再干净重启, + supervised = Reloader.event is not None + if supervised: + # 监督进程模式:拉取完整结束后再干净重启, # 避免 --reload 文件监听在拉取中途触发、加载到残缺代码 restart_note = "即将优雅重启加载新代码" elif _RELOAD_MODE: @@ -171,7 +268,7 @@ async def _check_and_update() -> None: notice += f"\n{restart_note}" logger.info(f"[auto_update] 更新完成,{restart_note}") await _notice(notice) - if Reloader.event is not None: + if supervised: Reloader.reload(delay=5) # 给群通知留出发送时间 elif config.auto_update_restart and not _RELOAD_MODE: # 延迟硬退出,交给进程守护工具重新拉起以加载新代码 diff --git a/src/plugins/auto_update/reloader.py b/src/plugins/auto_update/reloader.py deleted file mode 100644 index c39f33f..0000000 --- a/src/plugins/auto_update/reloader.py +++ /dev/null @@ -1,86 +0,0 @@ -"""进程监督式重启器 - -用 multiprocessing 监督进程包裹 nonebot.run: -- 父进程只负责拉起/终止真正的机器人子进程 -- 机器人子进程中调用 ``Reloader.reload()`` 通知父进程重启自己, - 全部代码与插件随之完整重载 -- 相比 --reload 的文件监听,重启时机完全由插件控制: - git pull 完整结束后才触发重启,不会加载到拉取一半的残缺代码 - -通过 ``install_reloader()`` 显式启用(仅应在 bot.py 中调用), -不启用时对 nb run 等启动方式零影响。 -""" - -import threading -from multiprocessing import get_context -from typing import Any - -import nonebot -from nonebot import get_plugin_config, logger - -from .config import Config - -_nb_run = nonebot.run - -_reloader_config = get_plugin_config(Config) - - -class ReloaderNotEnabledError(RuntimeError): - """Reloader 未启用(未通过 python bot.py 的监督进程模式启动)""" - - -class Reloader: - """跨进程重启开关:机器人进程中调用 reload() 请求父进程重启自己""" - - # 实际对象为 multiprocessing.Event(与 threading.Event 接口一致) - event: threading.Event | None = None - - @classmethod - def reload(cls, delay: int = 0) -> None: - """请求重启机器人进程 - - 参数: - delay: 延迟秒数(给群通知等收尾工作留出发送时间) - """ - if cls.event is None: - raise ReloaderNotEnabledError - if delay > 0: - threading.Timer(delay, cls.event.set).start() - return - cls.event.set() - - -def _run(ev: Any, *args: Any, **kwargs: Any) -> None: - Reloader.event = ev - _nb_run(*args, **kwargs) - - -def run(*args: Any, **kwargs: Any) -> None: - """监督进程入口:循环拉起机器人子进程,直到其自行退出或收到重启事件""" - should_exit = False - ctx = get_context("spawn") - while not should_exit: - event = ctx.Event() - process = ctx.Process(target=_run, args=(event, *args), kwargs=kwargs) - process.start() - while not should_exit: - if event.wait(1): - logger.info("[auto_update] 收到重启事件,正在重启机器人进程") - process.terminate() - process.join(_reloader_config.auto_update_reboot_grace_time) - if process.is_alive(): - logger.warning( - "[auto_update] 进程未在 " - f"{_reloader_config.auto_update_reboot_grace_time} " - "秒内优雅退出,强制结束" - ) - process.kill() - break - if not process.is_alive(): - # 子进程自行退出(如 Ctrl+C),监督进程随之结束 - should_exit = True - - -def install_reloader() -> None: - """将 nonebot.run 替换为监督进程版入口(仅应在 bot.py 中显式调用)""" - nonebot.run = run