mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: close plugin shutdown mutation races
This commit is contained in:
+13
-2
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6514,
|
||||
"edge_sha256": "eda9bc500158baa80dc5013eb4e409aa57cb70446e59a52b7e782cb9c58f9c34",
|
||||
"edge_count": 6525,
|
||||
"edge_sha256": "72b9416b5309bb51768b95b3c9da4b167242c3bc3f27d4e561a3f097267ca6a0",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -281,6 +281,7 @@
|
||||
"app.agent.middleware.skills -> app.agent.middleware",
|
||||
"app.agent.middleware.skills -> app.agent.middleware.utils",
|
||||
"app.agent.middleware.skills -> app.agent.policy",
|
||||
"app.agent.middleware.skills -> app.agent.policy.sanitizer",
|
||||
"app.agent.middleware.skills -> app.agent.skills",
|
||||
"app.agent.middleware.skills -> app.agent.skills.metadata",
|
||||
"app.agent.middleware.skills -> app.agent.tools",
|
||||
@@ -309,6 +310,7 @@
|
||||
"app.agent.middleware.summarization -> app.runtime.log",
|
||||
"app.agent.middleware.tool_selection -> app.agent",
|
||||
"app.agent.middleware.tool_selection -> app.agent.llm",
|
||||
"app.agent.middleware.tool_selection -> app.agent.llm.helper",
|
||||
"app.agent.middleware.tool_selection -> app.agent.tools",
|
||||
"app.agent.middleware.tool_selection -> app.agent.tools.tags",
|
||||
"app.agent.middleware.tool_selection -> app.runtime",
|
||||
@@ -435,6 +437,7 @@
|
||||
"app.agent.tools.base -> app.schemas.types",
|
||||
"app.agent.tools.catalog -> app.agent",
|
||||
"app.agent.tools.catalog -> app.agent.policy",
|
||||
"app.agent.tools.catalog -> app.agent.policy.contracts",
|
||||
"app.agent.tools.factory -> app.agent",
|
||||
"app.agent.tools.factory -> app.agent.llm",
|
||||
"app.agent.tools.factory -> app.agent.llm.capability",
|
||||
@@ -883,6 +886,7 @@
|
||||
"app.agent.tools.impl.query_doctor_report -> app.agent.tools.base",
|
||||
"app.agent.tools.impl.query_doctor_report -> app.agent.tools.tags",
|
||||
"app.agent.tools.impl.query_doctor_report -> app.doctor",
|
||||
"app.agent.tools.impl.query_doctor_report -> app.doctor.runner",
|
||||
"app.agent.tools.impl.query_doctor_report -> app.runtime",
|
||||
"app.agent.tools.impl.query_doctor_report -> app.runtime.log",
|
||||
"app.agent.tools.impl.query_download_tasks -> app.agent",
|
||||
@@ -1471,6 +1475,8 @@
|
||||
"app.agent.tools.impl.update_system_settings -> app.agent.tools.tags",
|
||||
"app.agent.tools.impl.update_system_settings -> app.application",
|
||||
"app.agent.tools.impl.update_system_settings -> app.application.configuration",
|
||||
"app.agent.tools.impl.update_system_settings -> app.application.plugin",
|
||||
"app.agent.tools.impl.update_system_settings -> app.application.plugin.runtime",
|
||||
"app.agent.tools.impl.update_system_settings -> app.runtime",
|
||||
"app.agent.tools.impl.update_system_settings -> app.runtime.events",
|
||||
"app.agent.tools.impl.update_system_settings -> app.runtime.log",
|
||||
@@ -2261,6 +2267,8 @@
|
||||
"app.api.endpoints.system -> app.application.messaging.message",
|
||||
"app.api.endpoints.system -> app.application.module",
|
||||
"app.api.endpoints.system -> app.application.network",
|
||||
"app.api.endpoints.system -> app.application.plugin",
|
||||
"app.api.endpoints.system -> app.application.plugin.runtime",
|
||||
"app.api.endpoints.system -> app.application.rules",
|
||||
"app.api.endpoints.system -> app.application.scheduling",
|
||||
"app.api.endpoints.system -> app.application.security",
|
||||
@@ -2287,6 +2295,7 @@
|
||||
"app.api.endpoints.system -> app.schemas",
|
||||
"app.api.endpoints.system -> app.schemas.common",
|
||||
"app.api.endpoints.system -> app.schemas.event",
|
||||
"app.api.endpoints.system -> app.schemas.exception",
|
||||
"app.api.endpoints.system -> app.schemas.response",
|
||||
"app.api.endpoints.system -> app.schemas.system",
|
||||
"app.api.endpoints.system -> app.schemas.token",
|
||||
@@ -2688,6 +2697,8 @@
|
||||
"app.application.plugin.install -> app.runtime.log",
|
||||
"app.application.plugin.install -> app.schemas",
|
||||
"app.application.plugin.install -> app.schemas.exception",
|
||||
"app.application.plugin.runtime -> app.schemas",
|
||||
"app.application.plugin.runtime -> app.schemas.types",
|
||||
"app.application.recognition -> app.application",
|
||||
"app.application.recognition -> app.application.configuration",
|
||||
"app.application.recognition -> app.schemas",
|
||||
|
||||
+118
-116
@@ -1,41 +1,41 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"generated_at": "2026-08-23T11:06:42.858188+00:00",
|
||||
"generated_at": "2026-08-23T13:47:08.442528+00:00",
|
||||
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
|
||||
"python": "3.14.3",
|
||||
"repeat": 3,
|
||||
"targets": {
|
||||
"app.startup.lifecycle": {
|
||||
"loaded_app_module_count": 362,
|
||||
"max_ms": 1022.231,
|
||||
"median_ms": 976.39,
|
||||
"min_ms": 967.395,
|
||||
"loaded_app_module_count": 367,
|
||||
"max_ms": 1591.175,
|
||||
"median_ms": 1049.37,
|
||||
"min_ms": 1022.351,
|
||||
"samples_ms": [
|
||||
1022.231,
|
||||
967.395,
|
||||
976.39
|
||||
1591.175,
|
||||
1049.37,
|
||||
1022.351
|
||||
]
|
||||
},
|
||||
"app.factory": {
|
||||
"loaded_app_module_count": 374,
|
||||
"max_ms": 989.21,
|
||||
"median_ms": 980.605,
|
||||
"min_ms": 951.564,
|
||||
"loaded_app_module_count": 379,
|
||||
"max_ms": 1198.166,
|
||||
"median_ms": 1083.805,
|
||||
"min_ms": 1009.309,
|
||||
"samples_ms": [
|
||||
980.605,
|
||||
989.21,
|
||||
951.564
|
||||
1009.309,
|
||||
1198.166,
|
||||
1083.805
|
||||
]
|
||||
},
|
||||
"app.main": {
|
||||
"loaded_app_module_count": 376,
|
||||
"max_ms": 1114.04,
|
||||
"median_ms": 1088.251,
|
||||
"min_ms": 1080.26,
|
||||
"loaded_app_module_count": 381,
|
||||
"max_ms": 1229.477,
|
||||
"median_ms": 1179.859,
|
||||
"min_ms": 1129.055,
|
||||
"samples_ms": [
|
||||
1080.26,
|
||||
1088.251,
|
||||
1114.04
|
||||
1129.055,
|
||||
1179.859,
|
||||
1229.477
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -46,26 +46,26 @@
|
||||
"samples": [
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 21,
|
||||
"startup_ms": 0.619,
|
||||
"full_lifespan_ms": 0.806,
|
||||
"enabled_component_count": 23,
|
||||
"startup_ms": 0.64,
|
||||
"full_lifespan_ms": 0.805,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.075,
|
||||
"数据库准备": 0.037,
|
||||
"HTTP 基础能力": 0.031,
|
||||
"后台任务登记器": 0.079,
|
||||
"数据库准备": 0.039,
|
||||
"HTTP 基础能力": 0.029,
|
||||
"领域依赖装配": 0.027,
|
||||
"数据库引擎预热": 0.024,
|
||||
"数据库连接预算": 0.025,
|
||||
"路由": 0.022,
|
||||
"模块服务": 0.024,
|
||||
"插件备份恢复": 0.021,
|
||||
"插件": 0.02,
|
||||
"定时器": 0.023,
|
||||
"监控器": 0.021,
|
||||
"待处理整理回放": 0.019,
|
||||
"命令服务": 0.022,
|
||||
"工作流": 0.022,
|
||||
"插件同步与启动收尾": 0.034
|
||||
"数据库连接预算": 0.023,
|
||||
"路由": 0.023,
|
||||
"模块服务": 0.021,
|
||||
"插件备份恢复": 0.024,
|
||||
"插件": 0.021,
|
||||
"定时器": 0.026,
|
||||
"监控器": 0.022,
|
||||
"待处理整理回放": 0.025,
|
||||
"命令服务": 0.025,
|
||||
"工作流": 0.021,
|
||||
"插件同步与启动收尾": 0.037
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -77,26 +77,26 @@
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 21,
|
||||
"startup_ms": 0.628,
|
||||
"enabled_component_count": 23,
|
||||
"startup_ms": 0.652,
|
||||
"full_lifespan_ms": 0.808,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.073,
|
||||
"数据库准备": 0.037,
|
||||
"HTTP 基础能力": 0.039,
|
||||
"领域依赖装配": 0.027,
|
||||
"数据库引擎预热": 0.025,
|
||||
"数据库连接预算": 0.025,
|
||||
"后台任务登记器": 0.079,
|
||||
"数据库准备": 0.039,
|
||||
"HTTP 基础能力": 0.033,
|
||||
"领域依赖装配": 0.029,
|
||||
"数据库引擎预热": 0.026,
|
||||
"数据库连接预算": 0.021,
|
||||
"路由": 0.022,
|
||||
"模块服务": 0.022,
|
||||
"模块服务": 0.023,
|
||||
"插件备份恢复": 0.021,
|
||||
"插件": 0.02,
|
||||
"定时器": 0.024,
|
||||
"监控器": 0.022,
|
||||
"待处理整理回放": 0.029,
|
||||
"命令服务": 0.02,
|
||||
"工作流": 0.021,
|
||||
"插件同步与启动收尾": 0.032
|
||||
"插件": 0.022,
|
||||
"定时器": 0.026,
|
||||
"监控器": 0.023,
|
||||
"待处理整理回放": 0.024,
|
||||
"命令服务": 0.023,
|
||||
"工作流": 0.025,
|
||||
"插件同步与启动收尾": 0.035
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -108,26 +108,26 @@
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 21,
|
||||
"startup_ms": 0.664,
|
||||
"full_lifespan_ms": 0.863,
|
||||
"enabled_component_count": 23,
|
||||
"startup_ms": 0.739,
|
||||
"full_lifespan_ms": 0.944,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.083,
|
||||
"数据库准备": 0.047,
|
||||
"HTTP 基础能力": 0.036,
|
||||
"后台任务登记器": 0.087,
|
||||
"数据库准备": 0.037,
|
||||
"HTTP 基础能力": 0.029,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.029,
|
||||
"数据库连接预算": 0.025,
|
||||
"路由": 0.026,
|
||||
"模块服务": 0.025,
|
||||
"插件备份恢复": 0.025,
|
||||
"插件": 0.028,
|
||||
"定时器": 0.026,
|
||||
"监控器": 0.021,
|
||||
"待处理整理回放": 0.021,
|
||||
"命令服务": 0.024,
|
||||
"工作流": 0.021,
|
||||
"插件同步与启动收尾": 0.036
|
||||
"数据库引擎预热": 0.024,
|
||||
"数据库连接预算": 0.021,
|
||||
"路由": 0.02,
|
||||
"模块服务": 0.02,
|
||||
"插件备份恢复": 0.024,
|
||||
"插件": 0.019,
|
||||
"定时器": 0.025,
|
||||
"监控器": 0.054,
|
||||
"待处理整理回放": 0.041,
|
||||
"命令服务": 0.034,
|
||||
"工作流": 0.025,
|
||||
"插件同步与启动收尾": 0.037
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -138,9 +138,9 @@
|
||||
"database_connections_started": 0
|
||||
}
|
||||
],
|
||||
"median_startup_ms": 0.628,
|
||||
"median_startup_ms": 0.652,
|
||||
"median_full_lifespan_ms": 0.808,
|
||||
"enabled_component_count": 21,
|
||||
"enabled_component_count": 23,
|
||||
"enabled_components": [
|
||||
"后台任务登记器",
|
||||
"数据库准备",
|
||||
@@ -157,6 +157,8 @@
|
||||
"监控器",
|
||||
"整理后台服务",
|
||||
"AI智能体会话",
|
||||
"插件事件入口",
|
||||
"事件尾任务结算",
|
||||
"插件后台服务",
|
||||
"事件投递屏障",
|
||||
"待处理整理回放",
|
||||
@@ -170,42 +172,18 @@
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 11,
|
||||
"startup_ms": 0.464,
|
||||
"full_lifespan_ms": 0.637,
|
||||
"startup_ms": 0.475,
|
||||
"full_lifespan_ms": 0.619,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.074,
|
||||
"后台任务登记器": 0.079,
|
||||
"数据库准备": 0.038,
|
||||
"HTTP 基础能力": 0.03,
|
||||
"领域依赖装配": 0.029,
|
||||
"数据库引擎预热": 0.026,
|
||||
"数据库连接预算": 0.025,
|
||||
"路由": 0.024,
|
||||
"模块服务": 0.024,
|
||||
"插件同步与启动收尾": 0.033
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 11,
|
||||
"startup_ms": 0.451,
|
||||
"full_lifespan_ms": 0.624,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.073,
|
||||
"数据库准备": 0.035,
|
||||
"HTTP 基础能力": 0.028,
|
||||
"领域依赖装配": 0.027,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.024,
|
||||
"数据库连接预算": 0.023,
|
||||
"路由": 0.023,
|
||||
"模块服务": 0.02,
|
||||
"插件同步与启动收尾": 0.031
|
||||
"模块服务": 0.022,
|
||||
"插件同步与启动收尾": 0.036
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -218,18 +196,42 @@
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 11,
|
||||
"startup_ms": 0.477,
|
||||
"full_lifespan_ms": 0.661,
|
||||
"startup_ms": 0.493,
|
||||
"full_lifespan_ms": 0.676,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.081,
|
||||
"数据库准备": 0.05,
|
||||
"后台任务登记器": 0.089,
|
||||
"数据库准备": 0.037,
|
||||
"HTTP 基础能力": 0.035,
|
||||
"领域依赖装配": 0.03,
|
||||
"数据库引擎预热": 0.026,
|
||||
"数据库连接预算": 0.024,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.025,
|
||||
"数据库连接预算": 0.021,
|
||||
"路由": 0.025,
|
||||
"模块服务": 0.023,
|
||||
"插件同步与启动收尾": 0.062
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 11,
|
||||
"startup_ms": 0.464,
|
||||
"full_lifespan_ms": 0.638,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.075,
|
||||
"数据库准备": 0.036,
|
||||
"HTTP 基础能力": 0.028,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.023,
|
||||
"数据库连接预算": 0.022,
|
||||
"路由": 0.024,
|
||||
"模块服务": 0.02,
|
||||
"插件同步与启动收尾": 0.026
|
||||
"模块服务": 0.021,
|
||||
"插件同步与启动收尾": 0.057
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -240,8 +242,8 @@
|
||||
"database_connections_started": 0
|
||||
}
|
||||
],
|
||||
"median_startup_ms": 0.464,
|
||||
"median_full_lifespan_ms": 0.637,
|
||||
"median_startup_ms": 0.475,
|
||||
"median_full_lifespan_ms": 0.638,
|
||||
"enabled_component_count": 11,
|
||||
"enabled_components": [
|
||||
"后台任务登记器",
|
||||
|
||||
+108
-154
@@ -69,6 +69,8 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
|
||||
"stop_agent": AsyncMock(return_value=True),
|
||||
"stop_transfer": AsyncMock(return_value=True),
|
||||
"quiesce_plugins": AsyncMock(return_value=True),
|
||||
"settle_events": AsyncMock(return_value=True),
|
||||
"quiesce_plugin_services": AsyncMock(return_value=True),
|
||||
"drain_events": AsyncMock(return_value=True),
|
||||
"finalize_plugins": MagicMock(return_value=True),
|
||||
"stop_modules": AsyncMock(),
|
||||
@@ -94,6 +96,16 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
|
||||
"quiesce_plugins",
|
||||
shutdown_steps["quiesce_plugins"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"settle_events",
|
||||
shutdown_steps["settle_events"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"quiesce_plugin_services",
|
||||
shutdown_steps["quiesce_plugin_services"],
|
||||
)
|
||||
monkeypatch.setattr(lifecycle, "drain_events", shutdown_steps["drain_events"])
|
||||
monkeypatch.setattr(lifecycle, "stop_modules", shutdown_steps["stop_modules"])
|
||||
monkeypatch.setattr(
|
||||
@@ -191,166 +203,83 @@ def test_lifespan_validation_failure_does_not_clear_outer_loop_owner(monkeypatch
|
||||
lifecycle.global_vars.clear_loop.assert_not_called()
|
||||
|
||||
|
||||
def test_lifespan_settles_plugin_handlers_before_legacy_hooks(monkeypatch) -> None:
|
||||
"""整理尾事件必须在 handler 停用后结算,并先于旧插件停机 hook。"""
|
||||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||||
order: list[str] = []
|
||||
for name in (
|
||||
"stop_transfer",
|
||||
"quiesce_plugins",
|
||||
"settle_events",
|
||||
"quiesce_plugin_services",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
):
|
||||
shutdown_steps[name].side_effect = (
|
||||
lambda current=name: order.append(current) or True
|
||||
)
|
||||
|
||||
async def run_lifespan() -> None:
|
||||
"""运行一个完整的隔离生命周期。"""
|
||||
async with lifecycle.lifespan(FastAPI()):
|
||||
pass
|
||||
|
||||
asyncio.run(run_lifespan())
|
||||
|
||||
assert order == [
|
||||
"stop_transfer",
|
||||
"quiesce_plugins",
|
||||
"settle_events",
|
||||
"quiesce_plugin_services",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
]
|
||||
|
||||
|
||||
_ORDERED_SHUTDOWN_STEPS = (
|
||||
"stop_plugin_monitor",
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"stop_transfer",
|
||||
"quiesce_plugins",
|
||||
"settle_events",
|
||||
"quiesce_plugin_services",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
"stop_modules",
|
||||
"close_http",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failing_step", "completed_steps", "blocked_steps"),
|
||||
[
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
("stop_plugin_monitor",),
|
||||
(
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
"stop_modules",
|
||||
"close_http",
|
||||
),
|
||||
),
|
||||
(
|
||||
"stop_monitor",
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
),
|
||||
(
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
"stop_modules",
|
||||
"close_http",
|
||||
),
|
||||
),
|
||||
(
|
||||
"stop_scheduler",
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
),
|
||||
(
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
"stop_modules",
|
||||
"close_http",
|
||||
),
|
||||
),
|
||||
(
|
||||
"stop_agent",
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
),
|
||||
(
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
"stop_modules",
|
||||
"close_http",
|
||||
),
|
||||
),
|
||||
(
|
||||
"quiesce_plugins",
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
),
|
||||
(
|
||||
"stop_transfer",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
"stop_modules",
|
||||
"close_http",
|
||||
),
|
||||
),
|
||||
(
|
||||
"stop_transfer",
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
),
|
||||
("drain_events", "finalize_plugins", "stop_modules", "close_http"),
|
||||
),
|
||||
(
|
||||
"drain_events",
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
"drain_events",
|
||||
),
|
||||
("finalize_plugins", "stop_modules", "close_http"),
|
||||
),
|
||||
(
|
||||
"finalize_plugins",
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
),
|
||||
("stop_modules", "close_http"),
|
||||
),
|
||||
],
|
||||
"failing_step",
|
||||
(
|
||||
"stop_plugin_monitor",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"stop_transfer",
|
||||
"quiesce_plugins",
|
||||
"settle_events",
|
||||
"quiesce_plugin_services",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
),
|
||||
)
|
||||
def test_lifespan_stops_releasing_dependencies_when_owner_does_not_converge(
|
||||
monkeypatch,
|
||||
failing_step,
|
||||
completed_steps,
|
||||
blocked_steps,
|
||||
):
|
||||
"""关键 owner 未收敛时不得关闭仍被活任务使用的后续依赖。"""
|
||||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||||
shutdown_steps[failing_step].return_value = False
|
||||
failed_index = _ORDERED_SHUTDOWN_STEPS.index(failing_step)
|
||||
completed_steps = _ORDERED_SHUTDOWN_STEPS[: failed_index + 1]
|
||||
blocked_steps = _ORDERED_SHUTDOWN_STEPS[failed_index + 1 :]
|
||||
|
||||
async def run_lifespan():
|
||||
"""启动并关闭隔离后的应用生命周期。"""
|
||||
@@ -520,6 +449,8 @@ def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch):
|
||||
"stop_scheduler",
|
||||
"stop_plugin_monitor",
|
||||
"quiesce_plugins",
|
||||
"settle_events",
|
||||
"quiesce_plugin_services",
|
||||
"finalize_plugins",
|
||||
):
|
||||
shutdown_steps[name].assert_not_called()
|
||||
@@ -543,6 +474,19 @@ async def test_event_drain_does_not_materialize_manager(monkeypatch) -> None:
|
||||
event_manager_type.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_settlement_keeps_tail_event_admission_open(monkeypatch) -> None:
|
||||
"""中间结算只等待在途 handler,不得提前封死旧 hook 的尾事件。"""
|
||||
event_manager = MagicMock()
|
||||
event_manager.drain_async = AsyncMock(return_value=True)
|
||||
event_manager_type = MagicMock()
|
||||
event_manager_type.get_existing_instance.return_value = event_manager
|
||||
monkeypatch.setattr(modules_initializer, "EventManager", event_manager_type)
|
||||
|
||||
assert await modules_initializer.settle_events() is True
|
||||
event_manager.drain_async.assert_awaited_once_with(seal=False)
|
||||
|
||||
|
||||
def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
|
||||
"""组件清单应显式冻结依赖、模式、启动/关闭顺序和超时预算。"""
|
||||
app = FastAPI()
|
||||
@@ -591,8 +535,10 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
|
||||
"监控器",
|
||||
"定时器",
|
||||
"AI智能体会话",
|
||||
"插件后台服务",
|
||||
"整理后台服务",
|
||||
"插件事件入口",
|
||||
"事件尾任务结算",
|
||||
"插件后台服务",
|
||||
"事件投递屏障",
|
||||
"插件",
|
||||
"模块服务",
|
||||
@@ -623,6 +569,8 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
|
||||
"定时器",
|
||||
"AI智能体会话",
|
||||
"整理后台服务",
|
||||
"插件事件入口",
|
||||
"事件尾任务结算",
|
||||
"插件后台服务",
|
||||
"事件投递屏障",
|
||||
"插件",
|
||||
@@ -638,6 +586,8 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
|
||||
"定时器",
|
||||
"AI智能体会话",
|
||||
"整理后台服务",
|
||||
"插件事件入口",
|
||||
"事件尾任务结算",
|
||||
"插件后台服务",
|
||||
"事件投递屏障",
|
||||
"插件",
|
||||
@@ -869,8 +819,10 @@ def test_lifespan_cleans_started_owners_after_late_startup_failure(monkeypatch):
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
"quiesce_plugins",
|
||||
"settle_events",
|
||||
"quiesce_plugin_services",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
"stop_modules",
|
||||
@@ -905,11 +857,13 @@ def test_startup_failure_cleanup_honors_transfer_fail_fast(monkeypatch):
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_agent",
|
||||
"quiesce_plugins",
|
||||
"stop_transfer",
|
||||
):
|
||||
_assert_completed_once(shutdown_steps[name])
|
||||
for name in (
|
||||
"quiesce_plugins",
|
||||
"settle_events",
|
||||
"quiesce_plugin_services",
|
||||
"drain_events",
|
||||
"finalize_plugins",
|
||||
"stop_modules",
|
||||
|
||||
@@ -477,6 +477,103 @@ async def test_cancelled_install_waits_for_rollback_before_releasing_lifecycle()
|
||||
rollback.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_checkpoint_cancellation_retains_mutation_owner() -> None:
|
||||
"""快照等待被连续取消时,lease 必须保留到快照子任务终态。"""
|
||||
admission = PluginMutationAdmission()
|
||||
checkpoint_started = asyncio.Event()
|
||||
checkpoint_release = asyncio.Event()
|
||||
checkpoint_finished = asyncio.Event()
|
||||
|
||||
async def checkpoint(_plugin_id: str) -> object:
|
||||
"""阻塞快照创建,直到测试确认 owner 仍被持有。"""
|
||||
checkpoint_started.set()
|
||||
await checkpoint_release.wait()
|
||||
checkpoint_finished.set()
|
||||
return object()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_command(
|
||||
checkpointer=checkpoint,
|
||||
mutation=admission.hold,
|
||||
).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
)
|
||||
await checkpoint_started.wait()
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert task.done() is False
|
||||
assert admission.seal() == 1
|
||||
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
|
||||
await asyncio.sleep(0.02)
|
||||
assert idle_waiter.done() is False
|
||||
|
||||
checkpoint_release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await idle_waiter
|
||||
assert checkpoint_finished.is_set()
|
||||
assert admission.active_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_rollback_cancellation_retains_mutation_owner() -> None:
|
||||
"""补偿等待被再次连续取消时,lease 必须保留到补偿子任务终态。"""
|
||||
admission = PluginMutationAdmission()
|
||||
install_started = asyncio.Event()
|
||||
rollback_started = asyncio.Event()
|
||||
rollback_release = asyncio.Event()
|
||||
rollback_finished = asyncio.Event()
|
||||
|
||||
async def install(*_args) -> tuple[bool, str]:
|
||||
"""阻塞包安装,使首次取消进入补偿路径。"""
|
||||
install_started.set()
|
||||
await asyncio.Event().wait()
|
||||
return True, "ok"
|
||||
|
||||
async def rollback(_checkpoint: object) -> None:
|
||||
"""阻塞文件补偿,直到测试确认 owner 仍被持有。"""
|
||||
rollback_started.set()
|
||||
await rollback_release.wait()
|
||||
rollback_finished.set()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_command(
|
||||
installer=install,
|
||||
rollback=rollback,
|
||||
mutation=admission.hold,
|
||||
).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
)
|
||||
await install_started.wait()
|
||||
task.cancel()
|
||||
await rollback_started.wait()
|
||||
assert admission.seal() == 1
|
||||
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
|
||||
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
await asyncio.sleep(0.02)
|
||||
assert task.done() is False
|
||||
assert idle_waiter.done() is False
|
||||
assert admission.active_count == 1
|
||||
|
||||
rollback_release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await idle_waiter
|
||||
assert rollback_finished.is_set()
|
||||
assert admission.active_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_persisted_list_is_restored_conservatively() -> None:
|
||||
"""清单写入已产生副作用但尚未返回时取消,也必须恢复原清单。"""
|
||||
|
||||
@@ -100,8 +100,8 @@ def test_lifecycle_records_load_failure_when_loader_returns_no_class():
|
||||
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
|
||||
|
||||
|
||||
def test_quiesce_keeps_instance_and_events_until_finalize():
|
||||
"""第一阶段仅停止插件生产者,事件 handler 和实例留到屏障后释放。"""
|
||||
def test_phased_quiesce_disables_events_before_hooks_and_keeps_instance():
|
||||
"""宿主先停用 handler,再在事件结算后执行旧 hook 并保留实例。"""
|
||||
order: list[str] = []
|
||||
|
||||
class DemoPlugin:
|
||||
@@ -129,14 +129,19 @@ def test_quiesce_keeps_instance_and_events_until_finalize():
|
||||
lifecycle, classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
|
||||
lifecycle.start("DemoPlugin")
|
||||
lifecycle._disable_events.reset_mock()
|
||||
lifecycle._disable_events.side_effect = (
|
||||
lambda _plugin_type: order.append("disable_events")
|
||||
)
|
||||
lifecycle._clear_modules.reset_mock()
|
||||
lifecycle._clear_tools.reset_mock()
|
||||
|
||||
assert lifecycle.quiesce() is True
|
||||
assert order == ["close", "stop_service"]
|
||||
assert lifecycle.quiesce_handlers() is True
|
||||
assert order == ["disable_events"]
|
||||
assert lifecycle.quiesce_services() is True
|
||||
assert order == ["disable_events", "close", "stop_service"]
|
||||
assert classes["DemoPlugin"] is DemoPlugin
|
||||
assert isinstance(running["DemoPlugin"], DemoPlugin)
|
||||
lifecycle._disable_events.assert_not_called()
|
||||
lifecycle._disable_events.assert_called_once_with(DemoPlugin)
|
||||
lifecycle._clear_modules.assert_not_called()
|
||||
lifecycle._clear_tools.assert_not_called()
|
||||
|
||||
@@ -194,6 +199,44 @@ def test_quiesce_runs_stop_service_after_close_failure_and_retries_missing_hook(
|
||||
assert running == {}
|
||||
|
||||
|
||||
def test_quiesce_does_not_close_resources_until_all_handlers_are_disabled():
|
||||
"""任一 handler 停用失败时不得调用可能破坏共享资源的旧停机 hook。"""
|
||||
close = MagicMock()
|
||||
|
||||
class DemoPlugin:
|
||||
"""提供可观察 close hook 的测试插件。"""
|
||||
|
||||
plugin_name = "演示插件"
|
||||
plugin_version = "1.0.0"
|
||||
|
||||
def init_plugin(self, _config):
|
||||
"""接受宿主初始化配置。"""
|
||||
|
||||
@staticmethod
|
||||
def get_state():
|
||||
"""保持插件事件 handler 启用。"""
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
"""记录资源关闭调用。"""
|
||||
close()
|
||||
|
||||
lifecycle, classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
|
||||
lifecycle.start("DemoPlugin")
|
||||
lifecycle._disable_events.side_effect = RuntimeError("disable failed")
|
||||
|
||||
assert lifecycle.quiesce() is False
|
||||
close.assert_not_called()
|
||||
assert lifecycle.finalize() is False
|
||||
assert "DemoPlugin" in classes
|
||||
assert "DemoPlugin" in running
|
||||
|
||||
lifecycle._disable_events.side_effect = None
|
||||
assert lifecycle.quiesce() is True
|
||||
close.assert_called_once_with()
|
||||
assert lifecycle.finalize() is True
|
||||
|
||||
|
||||
def test_legacy_stop_entry_remains_idempotent():
|
||||
"""旧 stop 先解绑事件、保持 None 返回,且重复调用不重复执行 hook。"""
|
||||
order: list[str] = []
|
||||
|
||||
@@ -582,11 +582,12 @@ def test_stop_plugin_monitor_returns_existing_manager_result(monkeypatch) -> Non
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_phase_plugin_shutdown_does_not_materialize_manager() -> None:
|
||||
"""两阶段入口在插件管理器尚未创建时都直接视为已收敛。"""
|
||||
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
|
||||
|
||||
@@ -616,7 +617,10 @@ async def test_quiesce_timeout_retains_future_owner_until_worker_finishes(
|
||||
release.wait(timeout=2)
|
||||
return True
|
||||
|
||||
manager._plugin_lifecycle.quiesce = MagicMock(side_effect=blocking_quiesce)
|
||||
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:
|
||||
@@ -627,9 +631,10 @@ async def test_quiesce_timeout_retains_future_owner_until_worker_finishes(
|
||||
lambda: thread_helper,
|
||||
)
|
||||
|
||||
assert await manager.quiesce_plugins(timeout=0.01) is False
|
||||
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_quiesce_future
|
||||
owner = manager._plugin_service_quiesce_future
|
||||
assert owner is not None
|
||||
assert owner.done() is False
|
||||
assert manager.finalize_plugins() is False
|
||||
@@ -659,7 +664,7 @@ async def test_quiesce_seals_runtime_until_new_lifespan_reopens(monkeypatch) ->
|
||||
),
|
||||
)
|
||||
manager = PluginManager()
|
||||
manager._plugin_lifecycle.quiesce = MagicMock(return_value=True)
|
||||
manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True)
|
||||
manager._plugin_lifecycle.start = MagicMock(
|
||||
return_value={"DemoPlugin": PluginRuntimeStatus.ACTIVE}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""插件可变事务停机准入的确定性测试。"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextvars import copy_context
|
||||
@@ -20,6 +21,42 @@ from app.schemas.plugin import PluginRuntimeStatus
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
class _GatedCondition:
|
||||
"""让指定测试线程在取得真实 Condition 前停住。"""
|
||||
|
||||
def __init__(self, blocked_thread_prefix: str) -> None:
|
||||
"""初始化内部 Condition 和可控的竞态闸门。"""
|
||||
self._condition = threading.Condition()
|
||||
self._blocked_thread_prefix = blocked_thread_prefix
|
||||
self._blocked = False
|
||||
self.attempted = threading.Event()
|
||||
self.release = threading.Event()
|
||||
|
||||
def __enter__(self):
|
||||
"""在目标线程首次进入时等待测试放行。"""
|
||||
if (
|
||||
threading.current_thread().name.startswith(self._blocked_thread_prefix)
|
||||
and not self._blocked
|
||||
):
|
||||
self._blocked = True
|
||||
self.attempted.set()
|
||||
if not self.release.wait(timeout=1):
|
||||
raise TimeoutError("测试未及时放行 Condition")
|
||||
return self._condition.__enter__()
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
"""把上下文退出委托给内部 Condition。"""
|
||||
return self._condition.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
def wait(self) -> bool:
|
||||
"""等待 admission 活动计数变化。"""
|
||||
return self._condition.wait()
|
||||
|
||||
def notify_all(self) -> None:
|
||||
"""唤醒全部等待 admission 空闲的线程。"""
|
||||
self._condition.notify_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_manager() -> Iterator[PluginManager]:
|
||||
"""构造隔离的插件管理器,并在用例结束后清除单例状态。"""
|
||||
@@ -60,6 +97,46 @@ def test_seal_rejects_new_root_but_allows_propagated_nested_lease() -> None:
|
||||
assert admission.reopen() is True
|
||||
|
||||
|
||||
def test_stale_propagated_context_is_rechecked_atomically_after_seal() -> None:
|
||||
"""外层退出后才取得 Condition 的复制上下文不得冒充嵌套事务。"""
|
||||
admission = PluginMutationAdmission()
|
||||
calls: list[str] = []
|
||||
outer = admission.hold("外层事务")
|
||||
outer.__enter__()
|
||||
propagated = copy_context()
|
||||
condition = _GatedCondition("stale-admission")
|
||||
admission._condition = condition
|
||||
outer_closed = False
|
||||
|
||||
def mutate() -> None:
|
||||
"""使用复制上下文尝试执行延迟到封口后的写入。"""
|
||||
with admission.hold("延迟嵌套事务"):
|
||||
calls.append("mutated")
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
thread_name_prefix="stale-admission",
|
||||
) as executor:
|
||||
future = executor.submit(propagated.run, mutate)
|
||||
assert condition.attempted.wait(timeout=1)
|
||||
outer.__exit__(None, None, None)
|
||||
outer_closed = True
|
||||
assert admission.seal() == 0
|
||||
admission.wait_until_idle()
|
||||
|
||||
condition.release.set()
|
||||
with pytest.raises(PluginMutationRejectedError):
|
||||
future.result(timeout=1)
|
||||
finally:
|
||||
condition.release.set()
|
||||
if not outer_closed:
|
||||
outer.__exit__(None, None, None)
|
||||
|
||||
assert calls == []
|
||||
assert admission.active_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quiesce_timeout_retains_admitted_owner_and_nested_reload(
|
||||
plugin_manager: PluginManager,
|
||||
@@ -72,7 +149,7 @@ async def test_quiesce_timeout_retains_admitted_owner_and_nested_reload(
|
||||
manager._plugin_lifecycle.reload = MagicMock(
|
||||
return_value=PluginRuntimeStatus.ACTIVE
|
||||
)
|
||||
manager._plugin_lifecycle.quiesce = MagicMock(return_value=True)
|
||||
manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True)
|
||||
manager._plugin_lifecycle.finalize = MagicMock(return_value=True)
|
||||
|
||||
async def mutate() -> PluginRuntimeStatus:
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""通用系统设置入口的插件 mutation 准入测试。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
from app.application.plugin import runtime as plugin_runtime
|
||||
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
PLUGIN_RUNTIME_KEYS = (
|
||||
SystemConfigKey.UserInstalledPlugins,
|
||||
SystemConfigKey.PluginInstances,
|
||||
SystemConfigKey.PluginFolders,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("config_key", PLUGIN_RUNTIME_KEYS)
|
||||
async def test_system_http_rejects_plugin_keys_after_admission_seal(
|
||||
config_key: SystemConfigKey,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""HTTP 通用设置入口在封口后不得写入插件运行态配置。"""
|
||||
admission = PluginMutationAdmission()
|
||||
admission.seal()
|
||||
config = MagicMock()
|
||||
config.async_set = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
plugin_runtime,
|
||||
"get_plugin_manager",
|
||||
lambda: SimpleNamespace(mutation=admission.hold),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_runtime_settings",
|
||||
lambda: SimpleNamespace(contains=lambda _key: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
|
||||
response = await system_endpoint.set_setting(config_key.value, {}, None)
|
||||
|
||||
assert response.success is False
|
||||
assert "停机阶段" in response.message
|
||||
config.async_set.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("config_key", PLUGIN_RUNTIME_KEYS)
|
||||
async def test_agent_rejects_plugin_keys_after_admission_seal(
|
||||
config_key: SystemConfigKey,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Agent 通用设置入口在封口后不得读取或写入插件运行态配置。"""
|
||||
admission = PluginMutationAdmission()
|
||||
admission.seal()
|
||||
config = MagicMock()
|
||||
config.async_set = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
plugin_runtime,
|
||||
"get_plugin_manager",
|
||||
lambda: SimpleNamespace(mutation=admission.hold),
|
||||
)
|
||||
tool = UpdateSystemSettingsTool(
|
||||
session_id="session-1",
|
||||
user_id="10001",
|
||||
system_config=config,
|
||||
)
|
||||
|
||||
payload = json.loads(await tool.run(setting_key=config_key.value, value={}))
|
||||
|
||||
assert payload["success"] is False
|
||||
assert "停机阶段" in payload["message"]
|
||||
config.get.assert_not_called()
|
||||
config.async_set.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_http_plugin_write_remains_owned_until_settled(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""HTTP 在途插件配置写入完成前,封口等待不得误判为空闲。"""
|
||||
admission = PluginMutationAdmission()
|
||||
write_started = asyncio.Event()
|
||||
write_release = asyncio.Event()
|
||||
|
||||
async def async_set(_key: str, _value: object) -> bool:
|
||||
"""阻塞配置写入,暴露 quiesce 等待窗口。"""
|
||||
write_started.set()
|
||||
await write_release.wait()
|
||||
return True
|
||||
|
||||
config = MagicMock()
|
||||
config.async_set = AsyncMock(side_effect=async_set)
|
||||
monkeypatch.setattr(
|
||||
plugin_runtime,
|
||||
"get_plugin_manager",
|
||||
lambda: SimpleNamespace(mutation=admission.hold),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_runtime_settings",
|
||||
lambda: SimpleNamespace(contains=lambda _key: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(system_endpoint.eventmanager, "async_send_event", AsyncMock())
|
||||
|
||||
task = asyncio.create_task(
|
||||
system_endpoint.set_setting(
|
||||
SystemConfigKey.UserInstalledPlugins.value,
|
||||
["DemoPlugin"],
|
||||
None,
|
||||
)
|
||||
)
|
||||
await write_started.wait()
|
||||
assert admission.seal() == 1
|
||||
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
|
||||
await asyncio.sleep(0.02)
|
||||
assert idle_waiter.done() is False
|
||||
|
||||
write_release.set()
|
||||
response = await task
|
||||
await idle_waiter
|
||||
assert response.success is True
|
||||
assert admission.active_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_plugin_write_remains_owned_until_saved_value_read(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Agent 插件配置事务在写入和结果读取完成前持续持有 lease。"""
|
||||
admission = PluginMutationAdmission()
|
||||
write_started = asyncio.Event()
|
||||
write_release = asyncio.Event()
|
||||
|
||||
async def async_set(_key: SystemConfigKey, _value: object) -> bool:
|
||||
"""阻塞 Agent 配置写入,暴露 quiesce 等待窗口。"""
|
||||
write_started.set()
|
||||
await write_release.wait()
|
||||
return True
|
||||
|
||||
config = MagicMock()
|
||||
config.get.side_effect = [[], ["DemoPlugin"]]
|
||||
config.async_set = AsyncMock(side_effect=async_set)
|
||||
monkeypatch.setattr(
|
||||
plugin_runtime,
|
||||
"get_plugin_manager",
|
||||
lambda: SimpleNamespace(mutation=admission.hold),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
||||
AsyncMock(),
|
||||
)
|
||||
tool = UpdateSystemSettingsTool(
|
||||
session_id="session-1",
|
||||
user_id="10001",
|
||||
system_config=config,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
tool.run(
|
||||
setting_key=SystemConfigKey.UserInstalledPlugins.value,
|
||||
value=["DemoPlugin"],
|
||||
)
|
||||
)
|
||||
await write_started.wait()
|
||||
assert admission.seal() == 1
|
||||
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
|
||||
await asyncio.sleep(0.02)
|
||||
assert idle_waiter.done() is False
|
||||
|
||||
write_release.set()
|
||||
payload = json.loads(await task)
|
||||
await idle_waiter
|
||||
assert payload["success"] is True
|
||||
assert payload["saved_value"] == ["DemoPlugin"]
|
||||
assert admission.active_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_plugin_system_config_does_not_resolve_plugin_runtime(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""非插件 SystemConfig 写入保持原路径且不依赖插件组合根。"""
|
||||
config = MagicMock()
|
||||
config.async_set = AsyncMock(return_value=True)
|
||||
|
||||
def fail_runtime_resolution():
|
||||
"""若非插件配置误取插件运行时则立即暴露回归。"""
|
||||
raise AssertionError("非插件配置不应解析插件运行时")
|
||||
|
||||
monkeypatch.setattr(plugin_runtime, "get_plugin_manager", fail_runtime_resolution)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_runtime_settings",
|
||||
lambda: SimpleNamespace(contains=lambda _key: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_endpoint,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(system_endpoint.eventmanager, "async_send_event", AsyncMock())
|
||||
|
||||
response = await system_endpoint.set_setting(
|
||||
SystemConfigKey.Directories.value,
|
||||
[],
|
||||
None,
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
config.async_set.assert_awaited_once_with(SystemConfigKey.Directories.value, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_non_plugin_config_does_not_resolve_plugin_runtime(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Agent 非插件配置写入保持既有读写和事件语义。"""
|
||||
config = MagicMock()
|
||||
config.get.side_effect = [{}, {"chatgpt": {"enabled": True}}]
|
||||
config.async_set = AsyncMock(return_value=True)
|
||||
|
||||
def fail_runtime_resolution():
|
||||
"""若 Agent 非插件配置误取插件运行时则立即暴露回归。"""
|
||||
raise AssertionError("非插件配置不应解析插件运行时")
|
||||
|
||||
monkeypatch.setattr(plugin_runtime, "get_plugin_manager", fail_runtime_resolution)
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
||||
AsyncMock(),
|
||||
)
|
||||
tool = UpdateSystemSettingsTool(
|
||||
session_id="session-1",
|
||||
user_id="10001",
|
||||
system_config=config,
|
||||
)
|
||||
|
||||
payload = json.loads(
|
||||
await tool.run(
|
||||
setting_key=SystemConfigKey.AIAgentConfig.value,
|
||||
value={"chatgpt": {"enabled": True}},
|
||||
)
|
||||
)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["changed"] is True
|
||||
config.async_set.assert_awaited_once()
|
||||
@@ -297,6 +297,8 @@ def test_failed_retry_schedule_registers_future_observer(monkeypatch) -> None:
|
||||
chain.retry_scheduler = MagicMock(schedule_retry=schedule_retry)
|
||||
future = MagicMock(spec=Future)
|
||||
event_loop = MagicMock()
|
||||
event_loop.is_running.return_value = True
|
||||
event_loop.is_closed.return_value = False
|
||||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", event_loop)
|
||||
|
||||
def submit(coroutine, loop):
|
||||
|
||||
Reference in New Issue
Block a user