diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index dc14e5ae0..737a91286 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -275,10 +275,6 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): "plugins", self.resolve_event_handler_instance, ) - # 开发者模式监测插件修改 - if settings.DEV or settings.PLUGIN_AUTO_RELOAD: - self.__start_monitor() - def resolve_event_handler_instance( self, owner_class: Type[Any], @@ -387,6 +383,11 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """返回配置重载日志使用的功能名称。""" return "插件文件修改监测" + def start_monitor(self): + """按当前配置启动插件文件修改监测。""" + if settings.DEV or settings.PLUGIN_AUTO_RELOAD: + self._plugin_monitor.start() + def reload_monitor(self): """ 重新加载插件文件修改监测 @@ -395,12 +396,6 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): enabled=settings.DEV or settings.PLUGIN_AUTO_RELOAD ) - def __start_monitor(self): - """ - 启用监测插件文件修改监测 - """ - self._plugin_monitor.start() - def stop_monitor(self): """ 停止监测插件文件修改监测 diff --git a/app/startup/plugins_initializer.py b/app/startup/plugins_initializer.py index 0f8ba26e8..439c2e14e 100644 --- a/app/startup/plugins_initializer.py +++ b/app/startup/plugins_initializer.py @@ -5,6 +5,7 @@ from app.runtime.compat.diagnostics import ( scan_plugin_legacy_imports, ) from app.runtime.compat.resource_imports import scan_plugin_resource_imports +from app.application.plugin.routes import register_plugin_api from app.runtime.config import global_vars from app.runtime.config import settings from app.runtime.extensions.plugin_manager import ( @@ -162,21 +163,15 @@ async def execute_task(loop, task_func, task_name): return [] -def register_plugin_api(): - """ - 插件启动后注册插件API - """ - from app.api.endpoints import plugin - plugin.register_plugin_api() - - def init_plugins(): """ 初始化插件 """ configure_plugin_services() - PluginManager().start() + plugin_manager = PluginManager() + plugin_manager.start() register_plugin_api() + plugin_manager.start_monitor() def stop_plugins(): @@ -185,7 +180,9 @@ def stop_plugins(): """ try: plugin_manager = PluginManager() - plugin_manager.stop() - plugin_manager.stop_monitor() + try: + plugin_manager.stop_monitor() + finally: + plugin_manager.stop() except Exception as e: logger.error(f"停止插件时发生错误:{e}", exc_info=True) diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index c7cdfc1a1..9de3eecd1 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6069, - "edge_sha256": "a47f8e0ee6d4855e106fefae05d5fa27beea9a1fd5710009260615d232af3c03", + "edge_count": 6067, + "edge_sha256": "a10a5353df10ba2817b49f6994eefd99266c853eda5119f36b2a68bafb9221ed", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -5871,12 +5871,10 @@ "app.startup.plugins_initializer -> app.adapters.system.plugin.dependency", "app.startup.plugins_initializer -> app.adapters.system.plugin.manifest", "app.startup.plugins_initializer -> app.adapters.system.plugin.package", - "app.startup.plugins_initializer -> app.api", - "app.startup.plugins_initializer -> app.api.endpoints", - "app.startup.plugins_initializer -> app.api.endpoints.plugin", "app.startup.plugins_initializer -> app.application", "app.startup.plugins_initializer -> app.application.plugin", "app.startup.plugins_initializer -> app.application.plugin.catalog", + "app.startup.plugins_initializer -> app.application.plugin.routes", "app.startup.plugins_initializer -> app.application.site", "app.startup.plugins_initializer -> app.db", "app.startup.plugins_initializer -> app.db.oper", diff --git a/tests/test_plugin_monitor_lifecycle.py b/tests/test_plugin_monitor_lifecycle.py new file mode 100644 index 000000000..eab3b3a17 --- /dev/null +++ b/tests/test_plugin_monitor_lifecycle.py @@ -0,0 +1,144 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from app.foundation.singleton import Singleton +from app.runtime.extensions.plugin.monitor import PluginMonitorController +from app.runtime.extensions.plugin.system import reset_plugin_system +from app.runtime.extensions.plugin_manager import PluginManager +from app.startup import plugins_initializer + + +def _reset_plugin_manager() -> None: + """清除插件管理器单例,保证构造时序测试彼此隔离。""" + Singleton._instances.pop((PluginManager, (), frozenset()), None) + + +@pytest.mark.parametrize( + ("dev", "auto_reload"), + ((True, False), (False, True)), +) +def test_plugin_manager_constructor_does_not_start_monitor_before_runtime( + monkeypatch, + dev: bool, + auto_reload: bool, +) -> None: + """组合根尚未装配时,构造插件管理器不得提前启动开发监控线程。""" + _reset_plugin_manager() + reset_plugin_system() + start = MagicMock() + monkeypatch.setattr( + "app.runtime.extensions.plugin_manager.settings", + SimpleNamespace( + DEV=dev, + PLUGIN_AUTO_RELOAD=auto_reload, + ROOT_PATH=MagicMock(), + ), + ) + monkeypatch.setattr(PluginMonitorController, "start", start) + + PluginManager() + + start.assert_not_called() + _reset_plugin_manager() + + +def test_init_plugins_starts_monitor_after_runtime_and_routes(monkeypatch) -> None: + """插件运行时和动态路由就绪后,启动层才允许文件监控接收变化。""" + order: list[str] = [] + manager = MagicMock() + manager.start.side_effect = lambda: order.append("plugins") + manager.start_monitor.side_effect = lambda: order.append("monitor") + monkeypatch.setattr( + plugins_initializer, + "configure_plugin_services", + lambda: order.append("services"), + ) + monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) + monkeypatch.setattr( + plugins_initializer, + "register_plugin_api", + lambda: order.append("routes"), + ) + + plugins_initializer.init_plugins() + + assert order == ["services", "plugins", "routes", "monitor"] + + +@pytest.mark.parametrize( + ("dev", "auto_reload", "expected_calls"), + ((True, False, 1), (False, True, 1), (False, False, 0)), +) +def test_start_monitor_respects_runtime_configuration( + monkeypatch, + dev: bool, + auto_reload: bool, + expected_calls: int, +) -> None: + """首次启动只在开发模式或插件自动重载启用时创建监控线程。""" + _reset_plugin_manager() + reset_plugin_system() + monkeypatch.setattr( + "app.runtime.extensions.plugin_manager.settings", + SimpleNamespace( + DEV=dev, + PLUGIN_AUTO_RELOAD=auto_reload, + ROOT_PATH=MagicMock(), + ), + ) + manager = PluginManager() + start = MagicMock() + manager._plugin_monitor.start = start + + manager.start_monitor() + + assert start.call_count == expected_calls + _reset_plugin_manager() + + +def test_config_change_reloads_monitor(monkeypatch) -> None: + """配置热更新继续使用重建语义,不复用首次启动入口。""" + _reset_plugin_manager() + reset_plugin_system() + monkeypatch.setattr( + "app.runtime.extensions.plugin_manager.settings", + SimpleNamespace( + DEV=False, + PLUGIN_AUTO_RELOAD=False, + ROOT_PATH=MagicMock(), + ), + ) + manager = PluginManager() + reload_monitor = MagicMock() + manager.reload_monitor = reload_monitor + + manager.on_config_changed() + + reload_monitor.assert_called_once_with() + _reset_plugin_manager() + + +def test_stop_plugins_stops_monitor_before_plugin_runtime(monkeypatch) -> None: + """关闭时先隔离文件变化,再停止插件实例。""" + order: list[str] = [] + manager = MagicMock() + manager.stop_monitor.side_effect = lambda: order.append("monitor") + manager.stop.side_effect = lambda: order.append("plugins") + monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) + + plugins_initializer.stop_plugins() + + assert order == ["monitor", "plugins"] + + +def test_stop_plugins_still_stops_runtime_when_monitor_stop_fails(monkeypatch) -> None: + """监控线程停止异常不得阻止插件实例释放资源。""" + manager = MagicMock() + manager.stop_monitor.side_effect = RuntimeError("monitor stop failed") + monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) + + plugins_initializer.stop_plugins() + + manager.stop.assert_called_once_with()