import asyncio import threading import time from concurrent.futures import ThreadPoolExecutor from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from app.foundation.singleton import Singleton from app.runtime.config import global_vars from app.runtime.extensions.plugin.dependency import ( PluginDependencyClassification, PluginDependencyInstallResult, ) from app.runtime.extensions.plugin.monitor import ( PluginChangeMonitor, PluginMonitorController, ) from app.runtime.extensions.plugin.admission import PluginMutationAdmission from app.runtime.extensions.plugin.system import reset_plugin_system from app.runtime.extensions.plugin_manager import PluginManager from app.schemas.plugin import PluginRuntimeStatus from app.startup.initializers import plugins as 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.classify_plugins.return_value = PluginDependencyClassification( ready=("ReadyPlugin",), missing_dependencies=("DependencyPending",), missing_source=("SourcePending",), ) manager.reopen_plugins.side_effect = lambda: order.append("reopen") or True manager.start.side_effect = lambda plugin_id: order.append(f"plugin:{plugin_id}") manager.start_monitor.side_effect = lambda **_kwargs: 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", "reopen", "plugin:ReadyPlugin", "routes", "monitor", ] manager.reopen_plugins.assert_called_once_with() manager.set_plugin_settling.assert_called_once_with(True) manager.start_monitor.assert_called_once_with(reopen=True) def test_plugin_manager_projects_dependency_classification_to_runtime_status() -> None: """真实管理器按分类字段写入三类启动状态,避免测试替身掩盖字段漂移。""" _reset_plugin_manager() manager = PluginManager() manager.apply_plugin_dependency_classification( PluginDependencyClassification( ready=("ReadyPlugin",), missing_dependencies=("DependencyPending",), missing_source=("SourcePending",), ) ) assert manager.get_plugin_runtime_statuses() == { "ReadyPlugin": PluginRuntimeStatus.READY, "DependencyPending": PluginRuntimeStatus.DEPENDENCY_PENDING, "SourcePending": PluginRuntimeStatus.SOURCE_MISSING, } _reset_plugin_manager() def test_plugin_manager_promotes_running_dependency_after_recovery() -> None: """依赖恢复后,运行中的插件状态必须允许后台流程触发重载。""" _reset_plugin_manager() manager = PluginManager() manager._plugin_registry.running["DependencyRecovered"] = object() manager._plugin_registry.set_runtime_status( "DependencyRecovered", PluginRuntimeStatus.DEPENDENCY_PENDING, ) manager.apply_plugin_dependency_classification( PluginDependencyClassification( ready=("DependencyRecovered",), missing_dependencies=(), missing_source=(), ) ) assert manager.get_plugin_runtime_statuses()["DependencyRecovered"] is ( PluginRuntimeStatus.READY ) _reset_plugin_manager() def _patch_sync_plugins(monkeypatch, manager: MagicMock) -> MagicMock: """隔离后台执行器并返回动态路由注册替身。""" async def execute(_loop, task_func, _task_name): return task_func() register = MagicMock() monkeypatch.setattr( global_vars, "CURRENT_EVENT_LOOP", asyncio.get_running_loop(), ) monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None) monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) monkeypatch.setattr(plugins_initializer, "execute_task", execute) monkeypatch.setattr(plugins_initializer, "register_plugin_api", register) dependency_result = ( manager.async_install_plugin_missing_dependencies_with_status.return_value ) manager.async_install_plugin_missing_dependencies_with_status = AsyncMock( return_value=dependency_result, ) manager.get_plugin_runtime_statuses.return_value = {} return register @pytest.mark.asyncio async def test_sync_plugins_rejects_before_configuring_mutable_services( monkeypatch, ) -> None: """启动后同步在 admission 封口后不重装配服务、不写包或运行态。""" admission = PluginMutationAdmission() admission.seal() manager = MagicMock() manager.mutation.side_effect = admission.hold configure = MagicMock() monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) monkeypatch.setattr(plugins_initializer, "configure_plugin_services", configure) assert await plugins_initializer.sync_plugins() is False configure.assert_not_called() manager.set_plugin_settling.assert_not_called() manager.sync.assert_not_called() @pytest.mark.asyncio async def test_sync_plugins_activates_ready_plugins_when_dependencies_fail( monkeypatch, ) -> None: """依赖恢复失败时仍激活无关的已就绪插件。""" manager = MagicMock() manager.sync.return_value = ["demo"] manager.async_install_plugin_missing_dependencies_with_status.return_value = ( PluginDependencyInstallResult(missing=["demo>=1"], success=False) ) manager.classify_plugins.return_value = PluginDependencyClassification( ready=("ReadyPlugin",), missing_dependencies=("DependencyPending",), missing_source=(), ) manager.running_plugins = {} register = _patch_sync_plugins(monkeypatch, manager) assert await plugins_initializer.sync_plugins() is True manager.start.assert_called_once_with("ReadyPlugin") manager.reload_plugin.assert_not_called() register.assert_called_once_with("ReadyPlugin") @pytest.mark.asyncio async def test_sync_plugins_loads_only_plugins_that_become_ready( monkeypatch, ) -> None: """后台依赖恢复后只启动尚未运行且当前已就绪的插件。""" manager = MagicMock() manager.sync.return_value = [] manager.async_install_plugin_missing_dependencies_with_status.return_value = ( PluginDependencyInstallResult(missing=["demo>=1"], success=True) ) manager.classify_plugins.return_value = PluginDependencyClassification( ready=("ReadyPlugin", "DependencyRecovered"), missing_dependencies=(), missing_source=("SourcePending",), ) running = {"ReadyPlugin": object()} manager.running_plugins = running def start(plugin_id: str) -> None: running[plugin_id] = object() manager.start.side_effect = start register = _patch_sync_plugins(monkeypatch, manager) assert await plugins_initializer.sync_plugins() is True manager.start.assert_called_once_with("DependencyRecovered") manager.reload_plugin.assert_not_called() register.assert_called_once_with("DependencyRecovered") @pytest.mark.asyncio async def test_sync_plugins_reloads_only_updated_running_plugins(monkeypatch) -> None: """源码同步只重载对应运行实例,不重启其他插件。""" manager = MagicMock() manager.sync.return_value = ["UpdatedPlugin"] manager.async_install_plugin_missing_dependencies_with_status.return_value = ( PluginDependencyInstallResult(missing=[], success=True) ) manager.classify_plugins.return_value = PluginDependencyClassification( ready=("StablePlugin", "UpdatedPlugin"), missing_dependencies=(), missing_source=(), ) manager.running_plugins = { "StablePlugin": object(), "UpdatedPlugin": object(), } register = _patch_sync_plugins(monkeypatch, manager) assert await plugins_initializer.sync_plugins() is True manager.reload_plugin.assert_called_once_with("UpdatedPlugin") manager.start.assert_not_called() register.assert_called_once_with("UpdatedPlugin") @pytest.mark.asyncio async def test_sync_plugins_reloads_running_plugin_after_dependency_recovery( monkeypatch, ) -> None: """依赖恢复后,已运行的旧实例必须切换到新源码。""" manager = MagicMock() manager.sync.return_value = [] manager.async_install_plugin_missing_dependencies_with_status.return_value = ( PluginDependencyInstallResult(missing=["demo>=1"], success=True) ) manager.classify_plugins.return_value = PluginDependencyClassification( ready=("DependencyRecovered",), missing_dependencies=(), missing_source=(), ) manager.running_plugins = {"DependencyRecovered": object()} register = _patch_sync_plugins(monkeypatch, manager) manager.get_plugin_runtime_statuses.return_value = { "DependencyRecovered": PluginRuntimeStatus.DEPENDENCY_PENDING, } assert await plugins_initializer.sync_plugins() is True manager.reload_plugin.assert_called_once_with("DependencyRecovered") manager.start.assert_not_called() register.assert_called_once_with("DependencyRecovered") @pytest.mark.asyncio async def test_sync_plugins_keeps_runtime_when_nothing_changed(monkeypatch) -> None: """源码和依赖均无变化时保留首次初始化结果。""" manager = MagicMock() manager.sync.return_value = [] manager.async_install_plugin_missing_dependencies_with_status.return_value = ( PluginDependencyInstallResult(missing=[], success=True) ) manager.classify_plugins.return_value = PluginDependencyClassification( ready=("ReadyPlugin",), missing_dependencies=(), missing_source=(), ) manager.running_plugins = {"ReadyPlugin": object()} register = _patch_sync_plugins(monkeypatch, manager) assert await plugins_initializer.sync_plugins() is False manager.start.assert_not_called() manager.reload_plugin.assert_not_called() register.assert_not_called() @pytest.mark.asyncio async def test_sync_plugins_keeps_event_loop_responsive_during_activation( monkeypatch, ) -> None: """插件初始化运行在线程池时,Web 事件循环仍可继续调度。""" manager = MagicMock() manager.sync.return_value = [] manager.async_install_plugin_missing_dependencies_with_status.return_value = ( PluginDependencyInstallResult(missing=[], success=True) ) manager.classify_plugins.return_value = PluginDependencyClassification( ready=("SlowPlugin",), missing_dependencies=(), missing_source=(), ) manager.running_plugins = {} activation_started = threading.Event() def slow_start(_plugin_id: str) -> None: activation_started.set() time.sleep(0.1) manager.start.side_effect = slow_start manager.async_install_plugin_missing_dependencies_with_status = AsyncMock( return_value=PluginDependencyInstallResult(missing=[], success=True), ) monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None) monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) monkeypatch.setattr(plugins_initializer, "register_plugin_api", MagicMock()) monkeypatch.setattr( plugins_initializer.global_vars, "CURRENT_EVENT_LOOP", asyncio.get_running_loop(), ) sync_task = asyncio.create_task(plugins_initializer.sync_plugins()) assert await asyncio.to_thread(activation_started.wait, 1) assert sync_task.done() is False assert await sync_task is True @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_plugin_monitor_waits_until_dependency_settlement(monkeypatch) -> None: """后台依赖收敛期间不启动文件监控,避免源码写入触发重复重载。""" _reset_plugin_manager() reset_plugin_system() monkeypatch.setattr( "app.runtime.extensions.plugin_manager.settings", SimpleNamespace( DEV=True, PLUGIN_AUTO_RELOAD=False, ROOT_PATH=MagicMock(), ), ) manager = PluginManager() start = MagicMock() reload_monitor = MagicMock() manager._plugin_monitor.start = start manager._plugin_monitor.reload = reload_monitor manager.set_plugin_settling(True) manager.start_monitor() manager.reload_monitor() start.assert_not_called() reload_monitor.assert_called_once_with(enabled=False) manager.set_plugin_settling(False) manager.start_monitor() start.assert_called_once_with() _reset_plugin_manager() def test_monitor_stop_keeps_thread_owned_until_it_really_exits() -> None: """停止超时后保留活线程引用,使后续停机调用仍能继续等待。""" started = threading.Event() release = threading.Event() def runner() -> None: """模拟暂时无法响应停止事件的文件监控循环。""" started.set() release.wait() controller = PluginMonitorController(runner=runner, log=MagicMock()) controller.start() assert started.wait(1) owned_thread = controller._thread try: assert controller.stop(timeout=0.01) is False assert controller._thread is owned_thread assert owned_thread is not None and owned_thread.is_alive() finally: release.set() assert controller.stop(timeout=1) is True assert controller._thread is None assert controller.stop(timeout=0) is True def test_monitor_stop_counts_lifecycle_lock_wait_in_timeout() -> None: """并发重建占用生命周期锁时,停止调用不得突破自身预算。""" controller = PluginMonitorController(runner=lambda: None, log=MagicMock()) lock_acquired = threading.Event() release = threading.Event() def hold_lifecycle_lock() -> None: """模拟配置重载在停止请求到达时仍持有生命周期锁。""" with controller._lifecycle_lock: lock_acquired.set() release.wait() holder = threading.Thread(target=hold_lifecycle_lock, daemon=True) holder.start() assert lock_acquired.wait(1) started_at = time.monotonic() try: assert controller.stop(timeout=0.01) is False assert time.monotonic() - started_at < 0.2 finally: release.set() holder.join(timeout=1) assert holder.is_alive() is False assert controller.stop(timeout=0) is True def test_shutdown_seal_blocks_reload_between_stop_and_start(monkeypatch) -> None: """停机封口落在热重载停启间隙时,旧重载不得再创建监控线程。""" reload_stopped = threading.Event() resume_reload = threading.Event() runner_started = threading.Event() controller = PluginMonitorController( runner=lambda: runner_started.set(), log=MagicMock(), ) original_stop = controller.stop def pause_after_stop(timeout: float = 5.0) -> bool: """把配置热重载稳定暂停在旧线程已停、新线程未启的窗口。""" stopped = original_stop(timeout=timeout) reload_stopped.set() resume_reload.wait() return stopped monkeypatch.setattr(controller, "stop", pause_after_stop) reload_thread = threading.Thread( target=lambda: controller.reload(enabled=True), daemon=True, ) reload_thread.start() assert reload_stopped.wait(1) try: assert controller.close(timeout=1) is True resume_reload.set() reload_thread.join(timeout=1) assert reload_thread.is_alive() is False assert runner_started.is_set() is False assert controller._thread is None finally: resume_reload.set() reload_thread.join(timeout=1) assert controller.reopen() is True controller.start() assert runner_started.wait(1) assert controller.close(timeout=1) is True def test_plugin_manager_stop_monitor_returns_controller_result(monkeypatch) -> None: """管理器停止入口透传线程收口结果和调用预算。""" _reset_plugin_manager() reset_plugin_system() manager = PluginManager() stop = MagicMock(return_value=False) manager._plugin_monitor.stop = stop try: assert manager.stop_monitor(timeout=0.25) is False stop.assert_called_once_with(timeout=0.25) finally: _reset_plugin_manager() def test_plugin_manager_start_monitor_can_reopen_new_lifespan(monkeypatch) -> None: """新应用生命周期可显式解除封口,再按运行配置启动监控。""" _reset_plugin_manager() reset_plugin_system() monkeypatch.setattr( "app.runtime.extensions.plugin_manager.settings", SimpleNamespace( DEV=True, PLUGIN_AUTO_RELOAD=False, ROOT_PATH=MagicMock(), ), ) manager = PluginManager() reopen = MagicMock(return_value=True) start = MagicMock() manager._plugin_monitor.reopen = reopen manager._plugin_monitor.start = start try: manager.start_monitor(reopen=True) reopen.assert_called_once_with() start.assert_called_once_with() finally: _reset_plugin_manager() def test_stop_plugin_monitor_does_not_materialize_manager() -> None: """插件运行时尚未创建时,独立停机入口直接视为已完成。""" _reset_plugin_manager() assert plugins_initializer.stop_plugin_monitor(timeout=0) is True assert PluginManager.get_existing_instance() is None def test_stop_plugin_monitor_returns_existing_manager_result(monkeypatch) -> None: """启动层入口只操作既有管理器,并透传超时失败。""" manager = MagicMock() manager.close_monitor.return_value = False manager_type = SimpleNamespace(get_existing_instance=lambda: manager) monkeypatch.setattr(plugins_initializer, "PluginManager", manager_type) assert plugins_initializer.stop_plugin_monitor(timeout=0.25) is False manager.close_monitor.assert_called_once_with(timeout=0.25) @pytest.mark.asyncio async def test_phased_plugin_shutdown_does_not_materialize_manager() -> None: """三个停机入口在插件管理器尚未创建时都直接视为已收敛。""" _reset_plugin_manager() assert await plugins_initializer.quiesce_plugins(timeout=0) is True assert await plugins_initializer.quiesce_plugin_services(timeout=0) is True assert plugins_initializer.finalize_plugins() is True assert PluginManager.get_existing_instance() is None @pytest.mark.asyncio async def test_quiesce_timeout_retains_future_owner_until_worker_finishes( monkeypatch, ) -> None: """同步插件 hook 超时后必须保留 Future,且未结束前拒绝卸载实例。""" _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() started = threading.Event() release = threading.Event() def blocking_quiesce() -> bool: """模拟无法由 asyncio 取消的同步旧插件 hook。""" started.set() release.wait(timeout=2) return True manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True) manager._plugin_lifecycle.quiesce_services = MagicMock( side_effect=blocking_quiesce, ) manager._plugin_lifecycle.finalize = MagicMock(return_value=True) try: with ThreadPoolExecutor(max_workers=1) as executor: thread_helper = SimpleNamespace(submit=executor.submit) monkeypatch.setattr( "app.runtime.extensions.plugin_manager.ThreadHelper", lambda: thread_helper, ) assert await manager.quiesce_plugins(timeout=1) is True assert await manager.quiesce_plugin_services(timeout=0.01) is False assert started.is_set() owner = manager._plugin_service_quiesce_future assert owner is not None assert owner.done() is False assert manager.finalize_plugins() is False manager._plugin_lifecycle.finalize.assert_not_called() release.set() assert await asyncio.wrap_future(owner) is True assert manager.finalize_plugins() is True manager._plugin_lifecycle.finalize.assert_called_once_with() finally: release.set() _reset_plugin_manager() @pytest.mark.asyncio async def test_quiesce_seals_runtime_until_new_lifespan_reopens(monkeypatch) -> None: """屏障前封口后 start/reload/config 不能重开 producer,新 lifespan 可显式恢复。""" _reset_plugin_manager() reset_plugin_system() monkeypatch.setattr( "app.runtime.extensions.plugin_manager.settings", SimpleNamespace( DEBUG=False, DEV=False, PLUGIN_AUTO_RELOAD=False, ROOT_PATH=MagicMock(), ), ) manager = PluginManager() manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True) manager._plugin_lifecycle.start = MagicMock( return_value={"DemoPlugin": PluginRuntimeStatus.ACTIVE} ) manager._plugin_lifecycle.reload = MagicMock( return_value=PluginRuntimeStatus.ACTIVE ) manager._plugin_lifecycle.stop = MagicMock(return_value=True) manager._plugin_lifecycle.initialize = MagicMock() manager._plugin_lifecycle._disable_events = MagicMock() manager._plugin_registry.remove = MagicMock() manager._plugin_registry.set_runtime_status = MagicMock() manager.classify_plugins = MagicMock() class DemoPlugin: """代表 quiesce 后仍由严格生命周期持有的运行实例。""" plugin_instance = DemoPlugin() manager._plugins["DemoPlugin"] = DemoPlugin manager._running_plugins["DemoPlugin"] = plugin_instance try: assert await manager.quiesce_plugins(timeout=1) is True assert manager.start("DemoPlugin") == { "DemoPlugin": PluginRuntimeStatus.LOAD_FAILED } assert manager.stop("DemoPlugin") is None assert manager.remove_plugin("DemoPlugin") is None assert ( manager.reload_plugin("DemoPlugin") is PluginRuntimeStatus.LOAD_FAILED ) manager.init_plugin("DemoPlugin", {}) manager.init_config() manager._plugin_lifecycle.start.assert_not_called() manager._plugin_lifecycle.stop.assert_not_called() manager._plugin_lifecycle.reload.assert_not_called() manager._plugin_lifecycle.initialize.assert_not_called() manager._plugin_lifecycle._disable_events.assert_not_called() manager._plugin_registry.remove.assert_not_called() manager._plugin_registry.set_runtime_status.assert_not_called() manager.classify_plugins.assert_not_called() assert manager._plugins["DemoPlugin"] is DemoPlugin assert manager._running_plugins["DemoPlugin"] is plugin_instance assert manager.reopen_plugins() is False manager._plugins.clear() manager._running_plugins.clear() assert manager.reopen_plugins() is True assert manager.start("DemoPlugin") == { "DemoPlugin": PluginRuntimeStatus.ACTIVE } assert ( manager.reload_plugin("DemoPlugin") is PluginRuntimeStatus.ACTIVE ) finally: _reset_plugin_manager() def test_plugin_monitor_skips_installing_plugin_until_package_write_finishes(tmp_path) -> None: """安装替换目录期间,文件事件不得抢先导入未完成的插件包。""" reload_plugin = MagicMock() monitor = PluginChangeMonitor( runtime_root=tmp_path, local_roots=lambda: [], stop_event=threading.Event(), recent_sync={}, federated_change=lambda _path: None, runtime_plugin=lambda _path: "DemoPlugin", local_candidate=lambda _path: None, sync_local=MagicMock(), reload_plugin=reload_plugin, dependency_manifest_status=lambda _path: None, watch=lambda *_args, **_kwargs: (), log=MagicMock(), monitor_suppressed=lambda plugin_id: plugin_id.lower() == "demoplugin", ) monitor._process_changes({("modified", str(tmp_path / "demo" / "plugin.py"))}) reload_plugin.assert_not_called() def test_plugin_monitor_suppression_is_reference_counted(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() with manager.suppress_plugin_monitor("DemoPlugin"): assert manager.is_plugin_monitor_suppressed("demoplugin") is True with manager.suppress_plugin_monitor("demoplugin"): assert manager.is_plugin_monitor_suppressed("DemoPlugin") is True assert manager.is_plugin_monitor_suppressed("DemoPlugin") is True assert manager.is_plugin_monitor_suppressed("DemoPlugin") is False _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") or True manager.stop.side_effect = lambda: order.append("plugins") manager_type = SimpleNamespace(get_existing_instance=lambda: manager) monkeypatch.setattr(plugins_initializer, "PluginManager", manager_type) assert plugins_initializer.stop_plugins() is True 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") manager_type = SimpleNamespace(get_existing_instance=lambda: manager) monkeypatch.setattr(plugins_initializer, "PluginManager", manager_type) assert plugins_initializer.stop_plugins() is False manager.stop.assert_called_once_with() def test_stop_plugins_does_not_materialize_manager() -> None: """插件初始化尚未创建管理器时,失败清理不得反向构造运行时。""" _reset_plugin_manager() assert plugins_initializer.stop_plugins() is True assert PluginManager.get_existing_instance() is None def test_stop_plugins_remains_idempotent(monkeypatch) -> None: """兼容停机入口可重复调用,并保持每次先停监控再停插件。""" order: list[str] = [] manager = MagicMock() manager.stop_monitor.side_effect = lambda: order.append("monitor") or True manager.stop.side_effect = lambda: order.append("plugins") manager_type = SimpleNamespace(get_existing_instance=lambda: manager) monkeypatch.setattr(plugins_initializer, "PluginManager", manager_type) assert plugins_initializer.stop_plugins() is True assert plugins_initializer.stop_plugins() is True assert order == ["monitor", "plugins", "monitor", "plugins"] def test_plugin_manager_legacy_stop_preserves_none_return() -> None: """公共 PluginManager.stop 委托单阶段停机后保持历史 None 返回 ABI。""" manager = object.__new__(PluginManager) manager._plugin_quiesce_lock = threading.RLock() manager._plugin_runtime_closed = False manager._plugin_quiesce_future = None manager._plugin_mutation_admission = PluginMutationAdmission() manager._plugin_lifecycle = SimpleNamespace(stop=MagicMock(return_value=True)) assert PluginManager.stop(manager, "DemoPlugin") is None manager._plugin_lifecycle.stop.assert_called_once_with("DemoPlugin") def test_config_reload_continues_after_legacy_stop() -> None: """配置热重载保持旧行为,stop 的 None 返回不得阻止重新分类和启动。""" manager = object.__new__(PluginManager) manager._plugin_quiesce_lock = threading.RLock() manager._plugin_runtime_closed = False manager._plugin_mutation_admission = PluginMutationAdmission() manager.stop = MagicMock(return_value=None) manager.classify_plugins = MagicMock( return_value=PluginDependencyClassification( ready=("ReadyPlugin",), missing_dependencies=(), missing_source=(), ) ) manager.apply_plugin_dependency_classification = MagicMock() manager.start = MagicMock() PluginManager.init_config(manager) manager.stop.assert_called_once_with() manager.classify_plugins.assert_called_once_with() manager.apply_plugin_dependency_classification.assert_called_once_with( manager.classify_plugins.return_value ) manager.start.assert_called_once_with("ReadyPlugin") def test_remove_plugin_clears_registry_after_legacy_stop() -> None: """卸载路径保持忽略旧 stop 返回值并继续清理注册表。""" manager = object.__new__(PluginManager) manager._plugin_quiesce_lock = threading.RLock() manager._plugin_runtime_closed = False manager._plugin_mutation_admission = PluginMutationAdmission() manager._plugin_lifecycle = SimpleNamespace(stop=MagicMock(return_value=None)) manager._plugin_registry = SimpleNamespace(remove=MagicMock()) PluginManager.remove_plugin(manager, "DemoPlugin") manager._plugin_lifecycle.stop.assert_called_once_with("DemoPlugin") manager._plugin_registry.remove.assert_called_once_with("DemoPlugin")