mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
1958 lines
62 KiB
Python
1958 lines
62 KiB
Python
"""
|
||
验证宿主关闭顺序、等待预算与在途任务资源释放。
|
||
"""
|
||
|
||
import asyncio
|
||
import signal
|
||
import threading
|
||
from contextlib import asynccontextmanager
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
|
||
from app.adapters.network import http as http_utils
|
||
from app.application.configuration import (
|
||
get_configured_system_config,
|
||
get_runtime_settings,
|
||
)
|
||
from app.runtime.config import settings as runtime_settings
|
||
from app.runtime.tasks import configure_task_registry, get_task_registry
|
||
from app.startup import lifecycle
|
||
from app.startup.composition.cache import CacheComposition
|
||
from app.startup.composition.system import compose_system_service
|
||
from app.startup.initializers import modules as modules_initializer
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _isolate_task_registry():
|
||
"""
|
||
生命周期用例结束后恢复未启动宿主时的默认任务登记器。
|
||
"""
|
||
configure_task_registry(None)
|
||
yield
|
||
configure_task_registry(None)
|
||
|
||
|
||
def _assert_completed_once(mock: MagicMock) -> None:
|
||
"""
|
||
根据同步或异步回调类型验证生命周期恰好执行一次。
|
||
|
||
:param mock: 对应生命周期回调的替身
|
||
"""
|
||
if isinstance(mock, AsyncMock):
|
||
mock.assert_awaited_once()
|
||
else:
|
||
mock.assert_called_once()
|
||
|
||
|
||
def _system_runtime():
|
||
"""
|
||
构造使用真实系统控制适配器的最小运行时。
|
||
"""
|
||
|
||
@asynccontextmanager
|
||
async def rule_group_mutation():
|
||
"""
|
||
提供本组测试不会进入的规则组事务替身。
|
||
"""
|
||
yield SimpleNamespace()
|
||
|
||
return SimpleNamespace(
|
||
system=compose_system_service(
|
||
settings=get_runtime_settings(),
|
||
system_config=get_configured_system_config(),
|
||
rule_group_mutation=rule_group_mutation,
|
||
)
|
||
)
|
||
|
||
|
||
def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
|
||
"""
|
||
隔离 lifespan 的外部依赖,并按名称注入一个关闭失败
|
||
"""
|
||
monkeypatch.setattr(runtime_settings, "MOVIEPILOT_SAFE_MODE", False)
|
||
monkeypatch.setattr(lifecycle.main_loop_registry, "register", MagicMock())
|
||
monkeypatch.setattr(lifecycle.main_loop_registry, "release", MagicMock())
|
||
monkeypatch.setattr(lifecycle.runtime_stop_state, "stop_system", MagicMock())
|
||
|
||
for name in (
|
||
"init_routers",
|
||
"init_plugins",
|
||
"init_scheduler",
|
||
"init_monitor",
|
||
"replay_pending_transfers",
|
||
"init_command",
|
||
"init_workflow",
|
||
"initialize_community_medals",
|
||
):
|
||
monkeypatch.setattr(lifecycle, name, MagicMock())
|
||
monkeypatch.setattr(lifecycle, "configure_plugin_runtime_services", MagicMock())
|
||
monkeypatch.setattr(lifecycle, "configure_plugin_services", MagicMock())
|
||
plugin_recovery = MagicMock()
|
||
plugin_recovery.replay = AsyncMock()
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"get_plugin_installation_recovery",
|
||
MagicMock(return_value=plugin_recovery),
|
||
)
|
||
monkeypatch.setattr(lifecycle, "init_modules", AsyncMock())
|
||
log_owner = object()
|
||
|
||
def initialize_log(app: FastAPI) -> None:
|
||
"""
|
||
发布隔离的日志 owner,覆盖正常启动和失败清理路径。
|
||
"""
|
||
app.state.log_writer = log_owner
|
||
app.state.log_shutdown_failed = False
|
||
|
||
logger_shutdown = MagicMock(return_value=True)
|
||
|
||
def stop_log(app: FastAPI) -> bool:
|
||
"""
|
||
按 mock 返回值模拟 owner 收敛并同步生命周期状态。
|
||
"""
|
||
converged = logger_shutdown.return_value is not False
|
||
app.state.log_shutdown_failed = not converged
|
||
if converged:
|
||
app.state.log_writer = None
|
||
return converged
|
||
|
||
logger_shutdown.side_effect = stop_log
|
||
message_shutdown = MagicMock(return_value=True)
|
||
|
||
def stop_message(app: FastAPI) -> bool:
|
||
"""
|
||
按 mock 返回值模拟消息 owner 的明确关闭结果。
|
||
"""
|
||
converged = message_shutdown.return_value is not False
|
||
app.state.message_shutdown_failed = not converged
|
||
return converged
|
||
|
||
message_shutdown.side_effect = stop_message
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"initialize_log_runtime",
|
||
MagicMock(side_effect=initialize_log),
|
||
)
|
||
monkeypatch.setattr(lifecycle, "stop_log_runtime", logger_shutdown)
|
||
monkeypatch.setattr(lifecycle, "initialize_message_runtime", MagicMock())
|
||
monkeypatch.setattr(lifecycle, "stop_message_runtime", message_shutdown)
|
||
|
||
# 启动期的引擎预热与额度核算也要打桩。不打的话这些用例会走真实的引擎创建,在测试
|
||
# 进程里留下一个从此无人释放的全局异步引擎——NullPool 不持连接、无害,但用例就不再
|
||
# 自洽了,而且额度核算还会去连库。
|
||
for name in ("get_engine", "get_global_async_engine", "check_connection_budget"):
|
||
monkeypatch.setattr(lifecycle, name, MagicMock())
|
||
database_prepare = MagicMock(
|
||
side_effect=lambda app: lifecycle.get_application_health(
|
||
app
|
||
).mark_database_ready()
|
||
)
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"prepare_database_component",
|
||
database_prepare,
|
||
)
|
||
|
||
system_chain = MagicMock()
|
||
monkeypatch.setattr(lifecycle, "SystemChain", MagicMock(return_value=system_chain))
|
||
monkeypatch.setattr(lifecycle, "init_extra", AsyncMock())
|
||
|
||
shutdown_steps = {
|
||
"backup_plugins": system_chain.backup_plugins,
|
||
"stop_plugin_monitor": MagicMock(return_value=True),
|
||
"stop_workflow": MagicMock(),
|
||
"stop_monitor": MagicMock(),
|
||
"stop_scheduler": MagicMock(),
|
||
"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(),
|
||
"close_http": AsyncMock(),
|
||
"message": message_shutdown,
|
||
"logger": logger_shutdown,
|
||
}
|
||
for name in (
|
||
"stop_workflow",
|
||
"stop_monitor",
|
||
"stop_scheduler",
|
||
"stop_plugin_monitor",
|
||
"finalize_plugins",
|
||
):
|
||
monkeypatch.setattr(lifecycle, name, shutdown_steps[name])
|
||
monkeypatch.setattr(lifecycle, "stop_agent", shutdown_steps["stop_agent"])
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"stop_transfer_runtime",
|
||
shutdown_steps["stop_transfer"],
|
||
)
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"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(
|
||
lifecycle,
|
||
"aclose_shared_async_transports",
|
||
shutdown_steps["close_http"],
|
||
)
|
||
|
||
if failing_step:
|
||
shutdown_steps[failing_step].side_effect = RuntimeError(
|
||
f"{failing_step} failed"
|
||
)
|
||
|
||
return shutdown_steps
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"failing_step",
|
||
[
|
||
"backup_plugins",
|
||
"stop_modules",
|
||
"close_http",
|
||
],
|
||
)
|
||
def test_lifespan_continues_after_each_shutdown_owner_failure(
|
||
monkeypatch,
|
||
failing_step,
|
||
):
|
||
"""
|
||
任一关闭阶段失败都不能跳过后续资源所有者
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch, failing_step=failing_step)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
lifecycle.runtime_stop_state.stop_system.assert_called_once_with()
|
||
lifecycle.init_modules.assert_awaited_once_with()
|
||
for step in shutdown_steps.values():
|
||
_assert_completed_once(step)
|
||
|
||
|
||
def test_lifespan_normal_mode_starts_full_runtime(monkeypatch):
|
||
"""
|
||
正常模式必须初始化插件及后台服务,并在退出时逐项停止。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
lifecycle.main_loop_registry.release.assert_called_once_with(
|
||
lifecycle.main_loop_registry.register.return_value
|
||
)
|
||
lifecycle.init_modules.assert_awaited_once_with()
|
||
lifecycle.prepare_database_component.assert_called_once()
|
||
lifecycle.configure_plugin_services.assert_called_once_with()
|
||
for name in (
|
||
"init_plugins",
|
||
"init_scheduler",
|
||
"init_monitor",
|
||
"replay_pending_transfers",
|
||
"init_command",
|
||
"init_workflow",
|
||
):
|
||
getattr(lifecycle, name).assert_called_once_with()
|
||
for step in shutdown_steps.values():
|
||
_assert_completed_once(step)
|
||
|
||
|
||
def test_lifespan_can_start_and_stop_resource_owners_twice(monkeypatch) -> None:
|
||
"""
|
||
同一应用连续两轮 lifespan 必须各自启动并关闭日志和消息 owner。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
app = FastAPI()
|
||
|
||
async def run_two_cycles() -> None:
|
||
"""
|
||
连续运行两轮隔离生命周期。
|
||
"""
|
||
async with lifecycle.lifespan(app):
|
||
pass
|
||
async with lifecycle.lifespan(app):
|
||
pass
|
||
|
||
asyncio.run(run_two_cycles())
|
||
|
||
assert lifecycle.initialize_log_runtime.call_count == 2
|
||
assert lifecycle.initialize_message_runtime.call_count == 2
|
||
assert shutdown_steps["message"].call_count == 2
|
||
assert shutdown_steps["logger"].call_count == 2
|
||
|
||
|
||
def test_lifespan_propagates_logger_nonconvergence(monkeypatch):
|
||
"""
|
||
最后一个日志 owner 未收敛时 lifespan 必须以关闭失败结束。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
shutdown_steps["logger"].return_value = False
|
||
|
||
async def run_lifespan() -> None:
|
||
"""
|
||
运行完整生命周期并触发日志 writer 的诚实失败结果。
|
||
"""
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
with pytest.raises(RuntimeError, match="日志写入资源未在关停预算内收敛"):
|
||
asyncio.run(run_lifespan())
|
||
|
||
for step in shutdown_steps.values():
|
||
_assert_completed_once(step)
|
||
lifecycle.main_loop_registry.release.assert_called_once_with(
|
||
lifecycle.main_loop_registry.register.return_value
|
||
)
|
||
|
||
|
||
def test_lifespan_propagates_message_nonconvergence(monkeypatch):
|
||
"""
|
||
消息 owner 未收敛时 lifespan 必须保留资源并明确失败。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
shutdown_steps["message"].return_value = False
|
||
|
||
async def run_lifespan() -> None:
|
||
"""
|
||
运行完整生命周期并触发消息资源失败。
|
||
"""
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
with pytest.raises(RuntimeError, match="消息资源未在关停预算内收敛"):
|
||
asyncio.run(run_lifespan())
|
||
|
||
_assert_completed_once(shutdown_steps["message"])
|
||
_assert_completed_once(shutdown_steps["logger"])
|
||
|
||
|
||
def test_runtime_gil_status_warns_when_free_threaded_runtime_enables_gil(monkeypatch):
|
||
"""
|
||
free-threaded 运行时退化为 GIL 模式时必须留下明确诊断。
|
||
"""
|
||
monkeypatch.setattr(lifecycle, "is_free_threaded_runtime", lambda: True)
|
||
monkeypatch.setattr(lifecycle, "is_gil_enabled", lambda: True)
|
||
warning = MagicMock()
|
||
monkeypatch.setattr(lifecycle.logger, "warning", warning)
|
||
|
||
lifecycle._log_runtime_gil_status()
|
||
|
||
warning.assert_called_once()
|
||
assert "已启用 GIL" in warning.call_args.args[0]
|
||
|
||
|
||
def test_lifespan_validation_failure_does_not_clear_outer_loop_owner(monkeypatch):
|
||
"""
|
||
当前生命周期尚未取得 owner 时,启动失败不得清理外层登记。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"validate_process_topology",
|
||
MagicMock(side_effect=RuntimeError("invalid topology")),
|
||
)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
with pytest.raises(RuntimeError, match="invalid topology"):
|
||
asyncio.run(run_lifespan())
|
||
|
||
lifecycle.main_loop_registry.register.assert_not_called()
|
||
lifecycle.main_loop_registry.release.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_scheduler",
|
||
"stop_plugin_monitor",
|
||
"backup_plugins",
|
||
"stop_workflow",
|
||
"stop_monitor",
|
||
"stop_agent",
|
||
"stop_transfer",
|
||
"quiesce_plugins",
|
||
"settle_events",
|
||
"quiesce_plugin_services",
|
||
"drain_events",
|
||
"finalize_plugins",
|
||
"message",
|
||
"stop_modules",
|
||
"close_http",
|
||
)
|
||
|
||
|
||
def test_scheduler_shutdown_callback_is_awaited_on_lifecycle_loop(monkeypatch) -> None:
|
||
"""
|
||
定时器关闭必须在生命周期主循环等待异步句柄收口。
|
||
"""
|
||
awaited = False
|
||
|
||
async def stop_scheduler_async() -> None:
|
||
"""
|
||
记录 manifest 回调返回的协程已被生命周期等待。
|
||
"""
|
||
nonlocal awaited
|
||
awaited = True
|
||
|
||
monkeypatch.setattr(lifecycle, "stop_scheduler", stop_scheduler_async)
|
||
component = next(
|
||
item
|
||
for item in lifecycle.build_lifecycle_components(FastAPI())
|
||
if item.name == "定时器"
|
||
)
|
||
|
||
assert component.stop is stop_scheduler_async
|
||
assert asyncio.run(
|
||
lifecycle.run_shutdown_step("定时器", component.stop)
|
||
) is True
|
||
assert awaited is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"failing_step",
|
||
(
|
||
"stop_plugin_monitor",
|
||
"stop_workflow",
|
||
"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,
|
||
):
|
||
"""
|
||
关键 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():
|
||
"""
|
||
启动并关闭隔离后的应用生命周期。
|
||
"""
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
for name in (*completed_steps, "logger"):
|
||
_assert_completed_once(shutdown_steps[name])
|
||
for name in blocked_steps:
|
||
shutdown_steps[name].assert_not_called()
|
||
|
||
|
||
def test_task_registry_nonconvergence_blocks_all_dependency_release(monkeypatch):
|
||
"""
|
||
最前置任务 owner 超时后不得继续释放插件、模块或 HTTP 依赖。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
shutdown = AsyncMock(return_value=False)
|
||
monkeypatch.setattr(lifecycle.TaskRegistry, "shutdown", shutdown)
|
||
app = FastAPI()
|
||
|
||
async def run_lifespan() -> None:
|
||
"""
|
||
运行后台登记器无法收敛的隔离生命周期。
|
||
"""
|
||
async with lifecycle.lifespan(app):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
shutdown.assert_awaited_once_with(timeout_seconds=30.0)
|
||
_assert_completed_once(shutdown_steps["stop_scheduler"])
|
||
for name, step in shutdown_steps.items():
|
||
if name in {"logger", "stop_scheduler"}:
|
||
_assert_completed_once(step)
|
||
else:
|
||
step.assert_not_called()
|
||
assert isinstance(app.state.task_registry, lifecycle.TaskRegistry)
|
||
|
||
|
||
def test_closed_task_registry_rejects_late_shutdown_tasks(monkeypatch) -> None:
|
||
"""
|
||
首屏障完成后,后续 stop hook 的晚到任务不得落回默认登记器。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
app = FastAPI()
|
||
|
||
async def run_lifespan() -> None:
|
||
"""
|
||
结束完整 lifespan 后验证当前发布的仍是已封口登记器。
|
||
"""
|
||
async with lifecycle.lifespan(app):
|
||
pass
|
||
registry = get_task_registry()
|
||
assert registry is app.state.task_registry
|
||
with pytest.raises(RuntimeError, match="正在关闭"):
|
||
registry.create(asyncio.sleep(0), owner="shutdown.late_task")
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
|
||
def test_plugin_settlement_cannot_bypass_task_registry_shutdown_budget(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
未收敛 settlement 必须交给首屏障判定,lifespan 不得提前无界等待。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
shutdown = AsyncMock(return_value=False)
|
||
monkeypatch.setattr(lifecycle.TaskRegistry, "shutdown", shutdown)
|
||
started = asyncio.Event()
|
||
release = asyncio.Event()
|
||
|
||
async def settle_plugins() -> None:
|
||
"""
|
||
模拟停机时仍未结束的插件同步任务。
|
||
"""
|
||
started.set()
|
||
await release.wait()
|
||
|
||
lifecycle.init_extra.side_effect = settle_plugins
|
||
|
||
async def run_lifespan() -> None:
|
||
"""
|
||
仅对关闭阶段计时,避免覆盖率启动开销污染停机预算断言。
|
||
"""
|
||
lifespan_context = lifecycle.lifespan(FastAPI())
|
||
await lifespan_context.__aenter__()
|
||
try:
|
||
await started.wait()
|
||
await asyncio.wait_for(
|
||
lifespan_context.__aexit__(None, None, None),
|
||
timeout=0.5,
|
||
)
|
||
finally:
|
||
release.set()
|
||
await asyncio.sleep(0)
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
shutdown.assert_awaited_once_with(timeout_seconds=30.0)
|
||
_assert_completed_once(shutdown_steps["stop_scheduler"])
|
||
for name, step in shutdown_steps.items():
|
||
if name in {"logger", "stop_scheduler"}:
|
||
_assert_completed_once(step)
|
||
else:
|
||
step.assert_not_called()
|
||
|
||
|
||
def test_lifespan_waits_for_uncancellable_plugin_settlement_before_shutdown(
|
||
monkeypatch,
|
||
):
|
||
"""
|
||
已进入同步 I/O 的 settlement 必须真实结束,才能备份和释放资源。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
order = []
|
||
shutdown_steps["backup_plugins"].side_effect = lambda: order.append("backup")
|
||
|
||
async def run_lifespan():
|
||
started = asyncio.Event()
|
||
release = asyncio.Event()
|
||
|
||
async def settle_plugins():
|
||
started.set()
|
||
try:
|
||
await release.wait()
|
||
except asyncio.CancelledError:
|
||
# 模拟 run_in_threadpool_to_completion:外层取消只能封住新工作,
|
||
# 已进入同步插件源码/依赖修改的调用仍持有 owner 到真实终态。
|
||
await release.wait()
|
||
order.append("settled")
|
||
|
||
lifecycle.init_extra.side_effect = settle_plugins
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
await started.wait()
|
||
asyncio.get_running_loop().call_later(0.02, release.set)
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
assert order[:2] == ["settled", "backup"]
|
||
|
||
|
||
def test_lifespan_configures_plugin_runtime_and_services_in_dependency_order(monkeypatch):
|
||
"""
|
||
插件 Runtime、模块持久化和应用服务必须按依赖顺序装配。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
order = []
|
||
lifecycle.configure_plugin_runtime_services.side_effect = lambda: order.append("runtime")
|
||
lifecycle.init_modules.side_effect = lambda: order.append("modules")
|
||
lifecycle.configure_plugin_services.side_effect = lambda: order.append("services")
|
||
lifecycle.get_plugin_installation_recovery.return_value.replay.side_effect = (
|
||
lambda: order.append("replay")
|
||
)
|
||
lifecycle.SystemChain.return_value.restore_plugins.side_effect = (
|
||
lambda: order.append("restore")
|
||
)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
assert order == ["runtime", "modules", "services", "replay", "restore"]
|
||
_assert_completed_once(shutdown_steps["close_http"])
|
||
|
||
|
||
def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch):
|
||
"""
|
||
安全模式只启动基础模块,并跳过插件及可选后台服务。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
monkeypatch.setattr(runtime_settings, "MOVIEPILOT_SAFE_MODE", True)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
lifecycle.init_modules.assert_awaited_once_with()
|
||
lifecycle.prepare_database_component.assert_called_once()
|
||
for name in (
|
||
"init_plugins",
|
||
"init_scheduler",
|
||
"init_monitor",
|
||
"replay_pending_transfers",
|
||
"init_command",
|
||
"init_workflow",
|
||
):
|
||
getattr(lifecycle, name).assert_not_called()
|
||
for name in (
|
||
"backup_plugins",
|
||
"stop_workflow",
|
||
"stop_monitor",
|
||
"stop_scheduler",
|
||
"stop_plugin_monitor",
|
||
"quiesce_plugins",
|
||
"settle_events",
|
||
"quiesce_plugin_services",
|
||
"finalize_plugins",
|
||
):
|
||
shutdown_steps[name].assert_not_called()
|
||
_assert_completed_once(shutdown_steps["stop_modules"])
|
||
_assert_completed_once(shutdown_steps["stop_agent"])
|
||
_assert_completed_once(shutdown_steps["stop_transfer"])
|
||
_assert_completed_once(shutdown_steps["drain_events"])
|
||
_assert_completed_once(shutdown_steps["close_http"])
|
||
_assert_completed_once(shutdown_steps["logger"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_event_drain_does_not_materialize_manager(monkeypatch) -> None:
|
||
"""
|
||
模块尚未创建事件总线时,停机屏障应直接收敛而不反向构造。
|
||
"""
|
||
event_manager_type = MagicMock()
|
||
event_manager_type.get_existing_instance.return_value = None
|
||
monkeypatch.setattr(modules_initializer, "EventManager", event_manager_type)
|
||
|
||
assert await modules_initializer.drain_events() is True
|
||
event_manager_type.get_existing_instance.assert_called_once_with()
|
||
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()
|
||
normal = lifecycle.get_lifecycle_manifest(app, safe_mode=False)
|
||
safe = lifecycle.get_lifecycle_manifest(app, safe_mode=True)
|
||
|
||
normal_start = [
|
||
item["name"]
|
||
for item in sorted(
|
||
(entry for entry in normal if entry["start_order"] is not None),
|
||
key=lambda entry: entry["start_order"],
|
||
)
|
||
]
|
||
normal_stop = [
|
||
item["name"]
|
||
for item in sorted(
|
||
(entry for entry in normal if entry["stop_order"] is not None),
|
||
key=lambda entry: entry["stop_order"],
|
||
)
|
||
]
|
||
safe_names = {item["name"] for item in safe}
|
||
|
||
assert normal_start == [
|
||
"文件日志",
|
||
"后台任务登记器",
|
||
"数据库准备",
|
||
"HTTP 基础能力",
|
||
"站点访问端口",
|
||
"Chain 外部端口",
|
||
"Chain 网络端口",
|
||
"领域依赖装配",
|
||
"数据库引擎预热",
|
||
"数据库连接预算",
|
||
"路由",
|
||
"插件运行时装配",
|
||
"模块服务",
|
||
"社区勋章缓存",
|
||
"插件服务装配",
|
||
"消息队列",
|
||
"插件备份恢复",
|
||
"插件",
|
||
"定时器",
|
||
"监控器",
|
||
"待处理整理回放",
|
||
"命令服务",
|
||
"工作流",
|
||
"插件同步与启动收尾",
|
||
]
|
||
assert normal_stop == [
|
||
"停止信号",
|
||
"定时器",
|
||
"后台任务登记器",
|
||
"插件变更监控",
|
||
"插件备份",
|
||
"工作流",
|
||
"命令服务",
|
||
"监控器",
|
||
"AI智能体会话",
|
||
"整理后台服务",
|
||
"插件事件入口",
|
||
"事件尾任务结算",
|
||
"插件后台服务",
|
||
"事件投递屏障",
|
||
"插件",
|
||
"消息队列",
|
||
"模块服务",
|
||
"Chain 网络端口",
|
||
"Chain 外部端口",
|
||
"站点访问端口",
|
||
"HTTP 基础能力",
|
||
"文件日志",
|
||
]
|
||
assert safe_names == {
|
||
"文件日志",
|
||
"后台任务登记器",
|
||
"数据库准备",
|
||
"HTTP 基础能力",
|
||
"站点访问端口",
|
||
"Chain 外部端口",
|
||
"Chain 网络端口",
|
||
"领域依赖装配",
|
||
"数据库引擎预热",
|
||
"数据库连接预算",
|
||
"路由",
|
||
"插件运行时装配",
|
||
"模块服务",
|
||
"社区勋章缓存",
|
||
"插件服务装配",
|
||
"消息队列",
|
||
"AI智能体会话",
|
||
"整理后台服务",
|
||
"事件投递屏障",
|
||
"停止信号",
|
||
"插件同步与启动收尾",
|
||
}
|
||
assert all(item["start_failure"] == "fail_fast" for item in normal)
|
||
assert {
|
||
item["name"]
|
||
for item in normal
|
||
if item["stop_failure"] == "fail_fast"
|
||
} == {
|
||
"插件变更监控",
|
||
"后台任务登记器",
|
||
"工作流",
|
||
"监控器",
|
||
"定时器",
|
||
"AI智能体会话",
|
||
"整理后台服务",
|
||
"插件事件入口",
|
||
"事件尾任务结算",
|
||
"插件后台服务",
|
||
"事件投递屏障",
|
||
"插件",
|
||
"消息队列",
|
||
"文件日志",
|
||
}
|
||
assert all(
|
||
item["stop_failure"] == "continue"
|
||
for item in normal
|
||
if item["name"]
|
||
not in {
|
||
"插件变更监控",
|
||
"后台任务登记器",
|
||
"工作流",
|
||
"监控器",
|
||
"定时器",
|
||
"AI智能体会话",
|
||
"整理后台服务",
|
||
"插件事件入口",
|
||
"事件尾任务结算",
|
||
"插件后台服务",
|
||
"事件投递屏障",
|
||
"插件",
|
||
"消息队列",
|
||
"文件日志",
|
||
}
|
||
)
|
||
assert all(
|
||
item["start_timeout_seconds"] or item["stop_timeout_seconds"]
|
||
for item in normal
|
||
)
|
||
|
||
|
||
def test_startup_step_records_duration_without_changing_result(monkeypatch):
|
||
"""
|
||
启动阶段计时必须保留返回值,并输出稳定的阶段名称和毫秒耗时。
|
||
"""
|
||
perf_counter = MagicMock(side_effect=[10.0, 10.125])
|
||
logger_info = MagicMock()
|
||
monkeypatch.setattr(lifecycle.time, "perf_counter", perf_counter)
|
||
monkeypatch.setattr(lifecycle.logger, "info", logger_info)
|
||
|
||
result = asyncio.run(
|
||
lifecycle.run_startup_step("契约测试", lambda: "ready")
|
||
)
|
||
|
||
assert result == "ready"
|
||
logger_info.assert_called_once_with(
|
||
"启动%s完成,耗时=%.2fms",
|
||
"契约测试",
|
||
125.0,
|
||
)
|
||
|
||
|
||
def test_lifespan_creates_global_async_engine_at_startup(monkeypatch):
|
||
"""
|
||
启动期必须把全局异步引擎建出来一次,让异步侧恢复 fail-fast
|
||
|
||
引擎改为惰性创建后,启动路径只碰得到同步引擎(init_db 建表),异步驱动没装、
|
||
异步 URL 拼错这类问题会一路推迟到第一个异步查询——用户拿到 500、调度任务静默死掉,
|
||
而不是启动就崩。create_async_engine 只校验 URL 与驱动导入、不建立连接,代价可以忽略。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
created = []
|
||
monkeypatch.setattr(lifecycle, "get_global_async_engine",
|
||
lambda: created.append(1) or MagicMock())
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
assert created, "启动期未创建全局异步引擎,异步侧的驱动/URL 错误会推迟到运行期才暴露"
|
||
|
||
|
||
def test_lifespan_creates_sync_engine_at_startup(monkeypatch):
|
||
"""
|
||
启动期也必须把同步引擎建出来一次,把首次创建钉在单线程期
|
||
|
||
数据库准备已统一进入 lifespan,所有受支持 ASGI 入口都会先由 init_db() 创建同步引擎;
|
||
随后的显式预热仍用于冻结顺序契约,确保同步/异步引擎都早于 Router、Module 和后台线程。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
created = []
|
||
monkeypatch.setattr(lifecycle, "get_engine",
|
||
lambda: created.append(1) or MagicMock())
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
assert created, "启动期未预热同步引擎,首次创建会退到已经放出上百个线程的运行期"
|
||
|
||
|
||
def test_lifespan_warms_engines_before_any_initializer(monkeypatch):
|
||
"""
|
||
两个引擎的预热必须排在 init_routers / init_modules 之前
|
||
|
||
排在后面时,预热失败会把已经初始化好的模块晾在那里:lifespan 的 try/finally 关停块
|
||
要到 yield 处才开始,在它之前抛异常,stop_modules() 根本没有机会执行。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
calls = []
|
||
monkeypatch.setattr(lifecycle, "get_engine", lambda: calls.append("sync_engine"))
|
||
monkeypatch.setattr(lifecycle, "get_global_async_engine",
|
||
lambda: calls.append("async_engine"))
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"init_routers",
|
||
lambda _app, _api_prefix: calls.append("init_routers"),
|
||
)
|
||
|
||
async def _init_modules():
|
||
"""
|
||
init_modules 在 v3 是协程,桩也必须可 await。
|
||
"""
|
||
calls.append("init_modules")
|
||
|
||
monkeypatch.setattr(lifecycle, "init_modules", _init_modules)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
asyncio.run(run_lifespan())
|
||
|
||
# 不钉同步/异步两者之间的先后:那一层顺序无所谓,要紧的是它们都在 init_* 之前
|
||
assert set(calls[:2]) == {"sync_engine", "async_engine"}, f"引擎预热没有排在最前面:{calls}"
|
||
assert calls[2:] == ["init_routers", "init_modules"], f"初始化顺序被打乱:{calls}"
|
||
|
||
|
||
def test_lifespan_fails_fast_when_async_engine_cannot_be_built(monkeypatch):
|
||
"""
|
||
异步引擎建不起来必须让启动直接失败,不能吞掉继续跑
|
||
|
||
吞掉等于把 fail-fast 又还回去了:进程起来了、健康检查是绿的,只有异步请求在报错。
|
||
"""
|
||
_patch_lifespan(monkeypatch)
|
||
|
||
def _boom():
|
||
"""
|
||
模拟异步驱动缺失。
|
||
"""
|
||
raise RuntimeError("no async driver")
|
||
|
||
monkeypatch.setattr(lifecycle, "get_global_async_engine", _boom)
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pass
|
||
|
||
with pytest.raises(RuntimeError, match="no async driver"):
|
||
asyncio.run(run_lifespan())
|
||
|
||
lifecycle.main_loop_registry.release.assert_called_once_with(
|
||
lifecycle.main_loop_registry.register.return_value
|
||
)
|
||
# 失败要发生在任何东西被初始化之前,否则模块起来了却没人关:关停块在 yield 处才开始
|
||
lifecycle.init_routers.assert_not_called()
|
||
lifecycle.init_modules.assert_not_called()
|
||
|
||
|
||
def test_uvicorn_signal_publishes_stop_before_server_exit(monkeypatch):
|
||
"""
|
||
Uvicorn 接管系统信号时必须先发布协作停止标志
|
||
"""
|
||
from app import main
|
||
|
||
calls = []
|
||
monkeypatch.setattr(
|
||
main.runtime_stop_state,
|
||
"stop_system",
|
||
lambda: calls.append("stop"),
|
||
)
|
||
monkeypatch.setattr(
|
||
main.uvicorn.Server,
|
||
"handle_exit",
|
||
lambda _self, _sig, _frame: calls.append("uvicorn"),
|
||
)
|
||
|
||
server = object.__new__(main.MoviePilotServer)
|
||
server.handle_exit(signal.SIGTERM, None)
|
||
|
||
assert calls == ["stop", "uvicorn"]
|
||
|
||
|
||
def test_application_preserves_stop_requested_before_startup(monkeypatch):
|
||
"""
|
||
启动流程不能清除初始化前已经发布的退出请求
|
||
"""
|
||
from app import main
|
||
|
||
stop_event = threading.Event()
|
||
stop_event.set()
|
||
monkeypatch.setattr(main.runtime_stop_state, "_system_event", stop_event)
|
||
calls = []
|
||
monkeypatch.setattr(
|
||
main.signal,
|
||
"signal",
|
||
lambda *_args: calls.append("signal"),
|
||
)
|
||
monkeypatch.setattr(main, "start_tray", lambda: calls.append("tray"))
|
||
monkeypatch.setattr(main, "run_api_server", lambda: calls.append("server"))
|
||
|
||
main.run_application()
|
||
|
||
assert stop_event.is_set()
|
||
assert calls == [
|
||
"signal",
|
||
"signal",
|
||
"tray",
|
||
"server",
|
||
]
|
||
|
||
|
||
def test_asgi_and_main_entrypoints_share_the_same_app_instance():
|
||
"""
|
||
ASGI 工厂入口与主程序入口必须暴露同一个 FastAPI 实例。
|
||
"""
|
||
from app import factory, main
|
||
|
||
assert main.app is factory.app
|
||
|
||
|
||
def test_lifespan_does_not_yield_after_migration_failure(monkeypatch):
|
||
"""
|
||
数据库迁移失败时 lifespan 必须 fail-fast 且不得发布 ready。
|
||
"""
|
||
migration_error = RuntimeError("migration failed")
|
||
_patch_lifespan(monkeypatch)
|
||
monkeypatch.setattr(
|
||
lifecycle,
|
||
"prepare_database_component",
|
||
MagicMock(side_effect=migration_error),
|
||
)
|
||
app = FastAPI()
|
||
|
||
async def run_lifespan():
|
||
async with lifecycle.lifespan(app):
|
||
pytest.fail("数据库迁移失败后不应进入服务阶段")
|
||
|
||
with pytest.raises(RuntimeError) as raised:
|
||
asyncio.run(run_lifespan())
|
||
|
||
assert raised.value is migration_error
|
||
assert app.state.moviepilot_health.is_ready is False
|
||
assert app.state.moviepilot_health.phase.value == "failed"
|
||
|
||
|
||
def test_lifespan_cleans_started_owners_after_late_startup_failure(monkeypatch):
|
||
"""
|
||
后段启动失败时应按同一停机策略回收已启动及部分启动的 owner。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
startup_error = RuntimeError("command startup failed")
|
||
lifecycle.init_command.side_effect = startup_error
|
||
app = FastAPI()
|
||
|
||
async def run_lifespan() -> None:
|
||
"""
|
||
运行一个在命令服务阶段失败的隔离生命周期。
|
||
"""
|
||
async with lifecycle.lifespan(app):
|
||
pytest.fail("命令服务启动失败后不应发布运行态")
|
||
|
||
with pytest.raises(RuntimeError) as raised:
|
||
asyncio.run(run_lifespan())
|
||
|
||
assert raised.value is startup_error
|
||
# 停止信号是无依赖的 stop-only owner:启动失败清理同样要先发出停机通知,
|
||
# 让仍在运行的后台任务尽早感知进程即将退出。
|
||
lifecycle.runtime_stop_state.stop_system.assert_called_once_with()
|
||
for name in (
|
||
"stop_plugin_monitor",
|
||
"backup_plugins",
|
||
"stop_monitor",
|
||
"stop_scheduler",
|
||
"stop_agent",
|
||
"stop_transfer",
|
||
"quiesce_plugins",
|
||
"settle_events",
|
||
"quiesce_plugin_services",
|
||
"drain_events",
|
||
"finalize_plugins",
|
||
"stop_modules",
|
||
"close_http",
|
||
):
|
||
_assert_completed_once(shutdown_steps[name])
|
||
shutdown_steps["stop_workflow"].assert_not_called()
|
||
_assert_completed_once(shutdown_steps["message"])
|
||
_assert_completed_once(shutdown_steps["logger"])
|
||
assert isinstance(app.state.task_registry, lifecycle.TaskRegistry)
|
||
assert get_task_registry() is app.state.task_registry
|
||
assert app.state.moviepilot_health.phase.value == "failed"
|
||
|
||
|
||
def test_startup_failure_cleanup_honors_transfer_fail_fast(monkeypatch):
|
||
"""
|
||
启动失败清理中整理 owner 未收敛时也不得继续释放插件和模块。
|
||
"""
|
||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||
lifecycle.init_command.side_effect = RuntimeError("command startup failed")
|
||
shutdown_steps["stop_transfer"].return_value = False
|
||
|
||
async def run_lifespan() -> None:
|
||
"""
|
||
运行后段失败且整理线程无法收敛的隔离生命周期。
|
||
"""
|
||
async with lifecycle.lifespan(FastAPI()):
|
||
pytest.fail("命令服务启动失败后不应发布运行态")
|
||
|
||
with pytest.raises(RuntimeError, match="command startup failed"):
|
||
asyncio.run(run_lifespan())
|
||
|
||
for name in (
|
||
"stop_plugin_monitor",
|
||
"backup_plugins",
|
||
"stop_monitor",
|
||
"stop_scheduler",
|
||
"stop_agent",
|
||
"stop_transfer",
|
||
):
|
||
_assert_completed_once(shutdown_steps[name])
|
||
for name in (
|
||
"quiesce_plugins",
|
||
"settle_events",
|
||
"quiesce_plugin_services",
|
||
"drain_events",
|
||
"finalize_plugins",
|
||
"stop_modules",
|
||
"close_http",
|
||
):
|
||
shutdown_steps[name].assert_not_called()
|
||
shutdown_steps["stop_workflow"].assert_not_called()
|
||
|
||
|
||
def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
|
||
"""
|
||
Uvicorn 启动不能清除数据库初始化阶段已经发布的停止请求
|
||
"""
|
||
from app import main
|
||
|
||
stop_event = threading.Event()
|
||
monkeypatch.setattr(main.runtime_stop_state, "_system_event", stop_event)
|
||
main.runtime_stop_state.stop_system()
|
||
|
||
async def serve(_self, sockets=None):
|
||
assert main.runtime_stop_state.is_system_stopped
|
||
|
||
monkeypatch.setattr(main.uvicorn.Server, "serve", serve)
|
||
server = object.__new__(main.MoviePilotServer)
|
||
asyncio.run(server.serve())
|
||
|
||
|
||
@pytest.mark.parametrize("endpoint_name", ["restart_system", "install_system_update"])
|
||
@pytest.mark.parametrize(
|
||
"initially_stopped",
|
||
[False, True],
|
||
ids=["running", "stopping"],
|
||
)
|
||
def test_restart_endpoint_failure_preserves_stop_state(
|
||
monkeypatch,
|
||
endpoint_name,
|
||
initially_stopped,
|
||
):
|
||
"""
|
||
重启或更新安装失败不能发布或撤销停止请求
|
||
"""
|
||
from app.api.endpoints import system
|
||
|
||
stop_event = threading.Event()
|
||
if initially_stopped:
|
||
stop_event.set()
|
||
monkeypatch.setattr(system.runtime_stop_state, "_system_event", stop_event)
|
||
monkeypatch.setattr(
|
||
"app.startup.composition.system.SystemHelper.can_restart",
|
||
MagicMock(return_value=True),
|
||
)
|
||
monkeypatch.setattr(
|
||
"app.startup.composition.system.SystemHelper.restart",
|
||
MagicMock(return_value=(False, "restart failed")),
|
||
)
|
||
monkeypatch.setattr(
|
||
"app.startup.composition.system.system_update_manager.request_install",
|
||
MagicMock(return_value=(True, "prepared")),
|
||
)
|
||
cancel_install = MagicMock()
|
||
monkeypatch.setattr(
|
||
"app.startup.composition.system.system_update_manager.cancel_install",
|
||
cancel_install,
|
||
)
|
||
runtime = _system_runtime()
|
||
|
||
if endpoint_name == "restart_system":
|
||
response = system.restart_system(None, runtime)
|
||
else:
|
||
response = system.install_system_update(None, runtime)
|
||
|
||
assert not response.success
|
||
assert stop_event.is_set() is initially_stopped
|
||
if endpoint_name == "install_system_update":
|
||
cancel_install.assert_called_once_with("restart failed")
|
||
else:
|
||
cancel_install.assert_not_called()
|
||
|
||
|
||
def test_upgrade_endpoint_retains_dev_mode_only(monkeypatch):
|
||
"""
|
||
旧升级入口只保留 Dev,Release 必须迁移到后台下载流程。
|
||
"""
|
||
from app.api.endpoints import system
|
||
|
||
monkeypatch.setattr(
|
||
"app.startup.composition.system.SystemHelper.can_restart",
|
||
MagicMock(return_value=True),
|
||
)
|
||
upgrade_dev = MagicMock(return_value=(True, "dev queued"))
|
||
monkeypatch.setattr(
|
||
"app.startup.composition.system.SystemHelper.upgrade_dev", upgrade_dev
|
||
)
|
||
runtime = _system_runtime()
|
||
|
||
dev_response = system.upgrade_system("dev", None, runtime)
|
||
release_response = system.upgrade_system("release", None, runtime)
|
||
legacy_default_response = system.upgrade_system(None, None, runtime)
|
||
|
||
assert dev_response.success
|
||
assert not release_response.success
|
||
assert not legacy_default_response.success
|
||
assert "update/check" in release_response.message
|
||
upgrade_dev.assert_called_once_with()
|
||
|
||
|
||
def test_command_restart_failure_does_not_publish_stop_request(monkeypatch):
|
||
"""
|
||
命令重启失败时进程仍在运行,不能提前发布停止请求
|
||
"""
|
||
from app.chain.system import SystemChain
|
||
from app.runtime.config import global_vars
|
||
|
||
stop_event = threading.Event()
|
||
monkeypatch.setattr(global_vars, "STOP_EVENT", stop_event)
|
||
monkeypatch.setattr(SystemChain, "backup_plugins", MagicMock())
|
||
restart = MagicMock(return_value=(False, "restart failed"))
|
||
monkeypatch.setattr("app.chain.system.SystemHelper.restart", restart)
|
||
|
||
chain = object.__new__(SystemChain)
|
||
chain.restart(channel=None, userid=None)
|
||
|
||
restart.assert_called_once_with()
|
||
assert not stop_event.is_set()
|
||
|
||
|
||
def test_stop_modules_continues_after_internal_owner_failure(monkeypatch):
|
||
"""
|
||
模块关闭编排中的单个失败不能阻断其余清理。
|
||
"""
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
dependencies["module"].side_effect = RuntimeError("module failed")
|
||
|
||
converged = asyncio.run(modules_initializer.stop_modules())
|
||
|
||
assert converged is False
|
||
for dependency in dependencies.values():
|
||
_assert_completed_once(dependency)
|
||
|
||
|
||
def test_stop_modules_propagates_false_without_skipping_later_cleanup(monkeypatch):
|
||
"""
|
||
关闭回调显式返回 False 时不得被转换为整体成功。
|
||
"""
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
dependencies["module"].return_value = False
|
||
|
||
converged = asyncio.run(modules_initializer.stop_modules())
|
||
|
||
assert converged is False
|
||
for dependency in dependencies.values():
|
||
_assert_completed_once(dependency)
|
||
|
||
|
||
def test_stop_modules_propagates_shared_thread_pool_nonconvergence(monkeypatch):
|
||
"""
|
||
共享线程池仍有活动 Future 时必须由模块服务关闭结果向上暴露。
|
||
"""
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
dependencies["thread"].return_value = False
|
||
|
||
converged = asyncio.run(modules_initializer.stop_modules())
|
||
|
||
assert converged is False
|
||
for dependency in dependencies.values():
|
||
_assert_completed_once(dependency)
|
||
|
||
|
||
def test_stop_modules_propagates_doh_nonconvergence(monkeypatch):
|
||
"""
|
||
DoH 查询线程未终止时必须由模块服务关闭结果向上暴露。
|
||
"""
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
dependencies["doh"].return_value = False
|
||
|
||
converged = asyncio.run(modules_initializer.stop_modules())
|
||
|
||
assert converged is False
|
||
for dependency in dependencies.values():
|
||
_assert_completed_once(dependency)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stop_modules_keeps_event_loop_responsive_during_sync_owner_wait(
|
||
monkeypatch,
|
||
):
|
||
"""
|
||
同步 owner 的有界等待不得占用主事件循环。
|
||
"""
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
release = threading.Event()
|
||
timer = threading.Timer(0.05, release.set)
|
||
dependencies["module"].side_effect = lambda: release.wait(timeout=1.0)
|
||
|
||
heartbeat = asyncio.create_task(asyncio.sleep(0.01))
|
||
timer.start()
|
||
try:
|
||
await modules_initializer.stop_modules()
|
||
finally:
|
||
timer.join(timeout=1.0)
|
||
|
||
assert heartbeat.done()
|
||
|
||
|
||
def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
|
||
"""
|
||
关闭时先收口 Web Agent,再关闭持久化准入和数据库任务。
|
||
"""
|
||
order = []
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"shutdown_web_agent_background_tasks",
|
||
AsyncMock(side_effect=lambda: order.append("web-agent")),
|
||
)
|
||
persistence = MagicMock()
|
||
persistence.begin_shutdown = MagicMock(
|
||
side_effect=lambda: order.append("persistence-admission")
|
||
)
|
||
persistence.shutdown = AsyncMock(side_effect=lambda: order.append("persistence"))
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"get_configured_agent_chat_persistence",
|
||
MagicMock(return_value=persistence),
|
||
)
|
||
database_state = {"active": True}
|
||
|
||
async def stop_database_runtime() -> None:
|
||
"""
|
||
模拟生产 worker 关闭后释放组合根句柄。
|
||
"""
|
||
order.append("database")
|
||
database_state["active"] = False
|
||
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"stop_database_runtime",
|
||
AsyncMock(side_effect=stop_database_runtime),
|
||
)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"database_runtime_active",
|
||
lambda: database_state["active"],
|
||
)
|
||
dependencies["reset_module_providers"].side_effect = lambda: order.append(
|
||
"providers"
|
||
) or True
|
||
dependencies["close_database"].side_effect = lambda: order.append("connection")
|
||
|
||
converged = asyncio.run(modules_initializer.stop_modules())
|
||
|
||
assert converged is True
|
||
assert order == [
|
||
"web-agent",
|
||
"persistence-admission",
|
||
"persistence",
|
||
"database",
|
||
"providers",
|
||
"connection",
|
||
]
|
||
|
||
|
||
def test_stop_modules_closes_shared_redis_pool_last(monkeypatch):
|
||
"""
|
||
Redis 连接池必须在 Provider 撤销等仍会读写缓存的步骤之后关闭,避免关闭后被重连。
|
||
"""
|
||
order = []
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"get_configured_agent_chat_persistence",
|
||
MagicMock(return_value=None),
|
||
)
|
||
monkeypatch.setattr(modules_initializer, "database_runtime_active", lambda: False)
|
||
dependencies["reset_module_providers"].side_effect = lambda: order.append(
|
||
"providers"
|
||
) or True
|
||
dependencies["close_database"].side_effect = lambda: order.append("connection")
|
||
dependencies["redis"].side_effect = lambda: order.append("redis")
|
||
dependencies["async_redis"].side_effect = lambda: order.append("async-redis")
|
||
|
||
asyncio.run(modules_initializer.stop_modules())
|
||
|
||
assert order == ["providers", "connection", "redis", "async-redis"]
|
||
|
||
def test_stop_modules_retains_providers_when_database_worker_remains_active(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
数据库 worker 未收敛时不得撤销 Provider 或关闭仍被使用的连接。
|
||
"""
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
persistence = MagicMock()
|
||
persistence.begin_shutdown = MagicMock()
|
||
persistence.shutdown = AsyncMock()
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"get_configured_agent_chat_persistence",
|
||
MagicMock(return_value=persistence),
|
||
)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"stop_database_runtime",
|
||
AsyncMock(side_effect=RuntimeError("database busy")),
|
||
)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"database_runtime_active",
|
||
lambda: True,
|
||
)
|
||
|
||
assert asyncio.run(modules_initializer.stop_modules()) is False
|
||
dependencies["reset_module_providers"].assert_not_called()
|
||
dependencies["close_database"].assert_not_awaited()
|
||
|
||
|
||
def test_reset_module_providers_preserves_reverse_order_after_failure(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
单个 Provider reset 失败时仍按既定逆序执行全部后续 owner。
|
||
"""
|
||
calls: list[str] = []
|
||
|
||
def fail() -> None:
|
||
"""
|
||
记录失败 owner 后抛出异常。
|
||
"""
|
||
calls.append("second")
|
||
raise RuntimeError("reset failed")
|
||
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"_module_provider_reset_steps",
|
||
lambda: (
|
||
("first", lambda: calls.append("first")),
|
||
("second", fail),
|
||
("third", lambda: calls.append("third")),
|
||
),
|
||
)
|
||
|
||
assert modules_initializer.reset_module_providers() is False
|
||
assert calls == ["first", "second", "third"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stop_modules_cancellation_does_not_skip_database_runtime_cleanup(
|
||
monkeypatch,
|
||
):
|
||
"""
|
||
模块关闭收到取消请求后仍应继续收口数据库 worker。
|
||
"""
|
||
started = asyncio.Event()
|
||
|
||
async def blocked_web_agent_shutdown():
|
||
started.set()
|
||
await asyncio.Event().wait()
|
||
|
||
_patch_module_shutdown_dependencies(monkeypatch)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"shutdown_web_agent_background_tasks",
|
||
blocked_web_agent_shutdown,
|
||
)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"wait_web_agent_background_tasks",
|
||
AsyncMock(),
|
||
)
|
||
persistence = MagicMock()
|
||
persistence.begin_shutdown = MagicMock()
|
||
persistence.shutdown = AsyncMock()
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"get_configured_agent_chat_persistence",
|
||
MagicMock(return_value=persistence),
|
||
)
|
||
database_worker_stopped = asyncio.Event()
|
||
database_state = {"active": True}
|
||
|
||
def mark_database_worker_stopped() -> None:
|
||
"""
|
||
记录取消路径仍执行了 worker 关闭调用。
|
||
"""
|
||
database_worker_stopped.set()
|
||
|
||
stop_database_runtime = AsyncMock(side_effect=mark_database_worker_stopped)
|
||
monkeypatch.setattr(modules_initializer, "stop_database_runtime", stop_database_runtime)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"database_runtime_active",
|
||
lambda: database_state["active"],
|
||
)
|
||
|
||
shutdown = asyncio.create_task(modules_initializer.stop_modules())
|
||
await started.wait()
|
||
shutdown.cancel()
|
||
completed = await shutdown
|
||
|
||
assert completed is False
|
||
assert database_worker_stopped.is_set()
|
||
stop_database_runtime.assert_awaited_once_with()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_shutdown_timeout_has_hard_bound_for_nonconverging_cleanup() -> None:
|
||
"""
|
||
关闭收尾不响应取消时,生命周期调用仍必须在预算内返回。
|
||
"""
|
||
started = asyncio.Event()
|
||
cancel_requested = asyncio.Event()
|
||
release = asyncio.Event()
|
||
settled = asyncio.Event()
|
||
|
||
async def nonconverging_shutdown() -> None:
|
||
started.set()
|
||
try:
|
||
await asyncio.Event().wait()
|
||
except asyncio.CancelledError:
|
||
cancel_requested.set()
|
||
await release.wait()
|
||
settled.set()
|
||
raise
|
||
|
||
started_at = asyncio.get_running_loop().time()
|
||
shutdown = asyncio.create_task(
|
||
lifecycle.run_shutdown_step(
|
||
"不可收敛阶段",
|
||
nonconverging_shutdown,
|
||
timeout_seconds=0.01,
|
||
)
|
||
)
|
||
await started.wait()
|
||
completed = await shutdown
|
||
|
||
elapsed = asyncio.get_running_loop().time() - started_at
|
||
assert completed is False
|
||
assert elapsed < 0.2
|
||
await asyncio.wait_for(cancel_requested.wait(), timeout=0.2)
|
||
assert not settled.is_set()
|
||
|
||
release.set()
|
||
await asyncio.wait_for(settled.wait(), timeout=0.2)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_shutdown_step_bounds_sync_owner_without_blocking_event_loop() -> None:
|
||
"""
|
||
同步 owner 超时后应及时返回,并继续持有 worker 直至真实终态。
|
||
"""
|
||
started = threading.Event()
|
||
release = threading.Event()
|
||
settled = threading.Event()
|
||
|
||
def blocking_shutdown() -> None:
|
||
started.set()
|
||
release.wait(timeout=1.0)
|
||
settled.set()
|
||
|
||
heartbeat = asyncio.create_task(asyncio.sleep(0.01))
|
||
shutdown = asyncio.create_task(
|
||
lifecycle.run_shutdown_step(
|
||
"同步阻塞 owner",
|
||
lifecycle.offload_shutdown_callback(blocking_shutdown),
|
||
timeout_seconds=0.02,
|
||
)
|
||
)
|
||
assert await asyncio.to_thread(started.wait, 0.2)
|
||
started_at = asyncio.get_running_loop().time()
|
||
completed = await shutdown
|
||
|
||
assert completed is False
|
||
assert asyncio.get_running_loop().time() - started_at < 0.2
|
||
assert heartbeat.done()
|
||
assert not settled.is_set()
|
||
|
||
release.set()
|
||
assert await asyncio.to_thread(settled.wait, 0.2)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_shutdown_step_calls_awaitable_wrapper_on_event_loop() -> None:
|
||
"""
|
||
普通 callable 可在主循环构造并返回需要等待的异步结果。
|
||
"""
|
||
loop = asyncio.get_running_loop()
|
||
|
||
def shutdown_wrapper() -> asyncio.Task[None]:
|
||
assert asyncio.get_running_loop() is loop
|
||
return loop.create_task(asyncio.sleep(0))
|
||
|
||
assert await lifecycle.run_shutdown_step(
|
||
"异步包装 owner",
|
||
shutdown_wrapper,
|
||
) is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_shutdown_step_reports_explicit_nonconvergence() -> None:
|
||
"""
|
||
同步和异步 owner 显式返回 False 时都必须向生命周期传播失败。
|
||
"""
|
||
|
||
async def async_nonconverging_shutdown() -> bool:
|
||
"""
|
||
模拟已经完成等待但仍持有资源的异步关闭入口。
|
||
"""
|
||
return False
|
||
|
||
assert await lifecycle.run_shutdown_step(
|
||
"同步 owner",
|
||
lambda: False,
|
||
) is False
|
||
assert await lifecycle.run_shutdown_step(
|
||
"异步 owner",
|
||
async_nonconverging_shutdown,
|
||
) is False
|
||
assert await lifecycle.run_shutdown_step(
|
||
"已收敛 owner",
|
||
lambda: None,
|
||
) is True
|
||
|
||
|
||
def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
|
||
"""
|
||
替换 stop_modules 的资源所有者,避免测试启动真实后台服务
|
||
"""
|
||
dependencies = {}
|
||
for name, method_name in (
|
||
("ModuleManager", "shutdown"),
|
||
("EventManager", "stop_async"),
|
||
("ThreadHelper", "shutdown"),
|
||
):
|
||
instance = MagicMock()
|
||
setattr(instance, method_name, MagicMock())
|
||
instance_type = MagicMock(return_value=instance)
|
||
instance_type.get_existing_instance.return_value = instance
|
||
monkeypatch.setattr(modules_initializer, name, instance_type)
|
||
key = name.removesuffix("Helper").removesuffix("Manager").lower()
|
||
dependencies[key] = getattr(instance, method_name)
|
||
|
||
# Redis 连接 owner 由缓存组合根按启动配置提供,这里模拟已选用 Redis 缓存。
|
||
redis = MagicMock()
|
||
redis.close = MagicMock()
|
||
redis_type = MagicMock(return_value=redis)
|
||
redis_type.get_existing_instance.return_value = redis
|
||
dependencies["redis"] = redis.close
|
||
|
||
stop_doh_composition = MagicMock()
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"stop_doh_composition",
|
||
stop_doh_composition,
|
||
)
|
||
dependencies["doh"] = stop_doh_composition
|
||
|
||
for name in (
|
||
"close_browser_sessions",
|
||
"stop_frontend",
|
||
"clear_temp",
|
||
):
|
||
dependency = MagicMock()
|
||
monkeypatch.setattr(modules_initializer, name, dependency)
|
||
dependencies[name] = dependency
|
||
|
||
close_image_proxy_block_log_coalescer = AsyncMock()
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"close_image_proxy_block_log_coalescer",
|
||
close_image_proxy_block_log_coalescer,
|
||
)
|
||
dependencies["close_image_proxy_block_log_coalescer"] = (
|
||
close_image_proxy_block_log_coalescer
|
||
)
|
||
|
||
stop_managed_resources = AsyncMock()
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"stop_managed_resources",
|
||
stop_managed_resources,
|
||
)
|
||
dependencies["stop_managed_resources"] = stop_managed_resources
|
||
|
||
async_redis = MagicMock()
|
||
async_redis.close = AsyncMock()
|
||
async_redis_type = MagicMock(return_value=async_redis)
|
||
async_redis_type.get_existing_instance.return_value = async_redis
|
||
composition = CacheComposition(
|
||
redis_enabled=True,
|
||
sync_redis_owner=redis_type,
|
||
async_redis_owner=async_redis_type,
|
||
)
|
||
monkeypatch.setattr(modules_initializer, "get_cache_composition", lambda: composition)
|
||
dependencies["async_redis"] = async_redis.close
|
||
close_database = AsyncMock()
|
||
monkeypatch.setattr(modules_initializer, "close_database", close_database)
|
||
dependencies["close_database"] = close_database
|
||
reset_module_providers = MagicMock(return_value=True)
|
||
monkeypatch.setattr(
|
||
modules_initializer,
|
||
"reset_module_providers",
|
||
reset_module_providers,
|
||
)
|
||
dependencies["reset_module_providers"] = reset_module_providers
|
||
return dependencies
|
||
|
||
|
||
def test_browser_sessions_close_before_managed_resources(monkeypatch) -> None:
|
||
"""
|
||
显示等宿主资源必须晚于浏览器会话释放,避免存活上下文失去依赖。
|
||
"""
|
||
calls: list[str] = []
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
dependencies["close_browser_sessions"].side_effect = lambda: calls.append("browser")
|
||
|
||
async def stop_resources() -> None:
|
||
calls.append("resources")
|
||
|
||
dependencies["stop_managed_resources"].side_effect = stop_resources
|
||
|
||
asyncio.run(modules_initializer.stop_modules())
|
||
|
||
assert calls == ["browser", "resources"]
|
||
|
||
|
||
def test_module_shutdown_waits_for_image_proxy_log_coalescer(monkeypatch) -> None:
|
||
"""
|
||
模块关闭必须等待图片安全日志的在途聚合任务收口。
|
||
"""
|
||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||
|
||
asyncio.run(modules_initializer.stop_modules())
|
||
|
||
dependencies["close_image_proxy_block_log_coalescer"].assert_awaited_once_with()
|
||
|
||
|
||
def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
|
||
"""
|
||
最终 HTTP 关闭必须等待真实 LRU 淘汰任务并消费其异常
|
||
"""
|
||
|
||
class FakeTransport:
|
||
"""
|
||
用事件控制关闭时机,并让首个传输实例模拟关闭失败。
|
||
"""
|
||
created = []
|
||
|
||
def __init__(self, **_kwargs):
|
||
self.close_started = asyncio.Event()
|
||
self.release_close = asyncio.Event()
|
||
self.closed = False
|
||
self.fail_on_close = not self.created
|
||
if not self.fail_on_close:
|
||
self.release_close.set()
|
||
self.created.append(self)
|
||
|
||
async def aclose(self):
|
||
"""
|
||
等待测试释放关闭屏障后标记已关闭,首个实例随后抛出模拟故障。
|
||
"""
|
||
self.close_started.set()
|
||
await self.release_close.wait()
|
||
self.closed = True
|
||
if self.fail_on_close:
|
||
raise RuntimeError("eviction close failed")
|
||
|
||
monkeypatch.setattr(http_utils, "_MAX_SHARED_TRANSPORTS_PER_LOOP", 1)
|
||
monkeypatch.setattr(http_utils.httpx2, "AsyncHTTPTransport", FakeTransport)
|
||
|
||
async def run_test():
|
||
transport_kwargs = {
|
||
"proxy": None,
|
||
"verify": True,
|
||
"http2": False,
|
||
"max_keepalive_connections": 1,
|
||
"max_connections": 1,
|
||
}
|
||
evicted_transport = http_utils._get_shared_async_transport(
|
||
**transport_kwargs,
|
||
keepalive_expiry=1,
|
||
)
|
||
active_transport = http_utils._get_shared_async_transport(
|
||
**transport_kwargs,
|
||
keepalive_expiry=2,
|
||
)
|
||
await asyncio.wait_for(evicted_transport.close_started.wait(), timeout=1)
|
||
|
||
loop = asyncio.get_running_loop()
|
||
with http_utils._shared_async_transports_lock:
|
||
eviction_tasks = [
|
||
task
|
||
for task in http_utils._pending_eviction_tasks
|
||
if task.get_loop() is loop
|
||
]
|
||
assert len(eviction_tasks) == 1
|
||
|
||
close_task = asyncio.create_task(http_utils.aclose_shared_async_transports())
|
||
await asyncio.sleep(0)
|
||
try:
|
||
assert not close_task.done()
|
||
evicted_transport.release_close.set()
|
||
await close_task
|
||
await asyncio.sleep(0)
|
||
assert eviction_tasks[0].done()
|
||
assert isinstance(eviction_tasks[0].exception(), RuntimeError)
|
||
assert evicted_transport.closed
|
||
assert active_transport.closed
|
||
with http_utils._shared_async_transports_lock:
|
||
assert not any(
|
||
task.get_loop() is loop
|
||
for task in http_utils._pending_eviction_tasks
|
||
)
|
||
finally:
|
||
evicted_transport.release_close.set()
|
||
active_transport.release_close.set()
|
||
await asyncio.gather(close_task, return_exceptions=True)
|
||
await http_utils.aclose_shared_async_transports()
|
||
|
||
asyncio.run(run_test())
|
||
|
||
|
||
def test_shared_http_close_ignores_eviction_from_other_loop():
|
||
"""
|
||
当前事件循环关闭不能等待其他循环持有的淘汰任务
|
||
"""
|
||
ready = threading.Event()
|
||
release = threading.Event()
|
||
failures = []
|
||
state = {}
|
||
|
||
def run_foreign_loop():
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
async def delayed_close():
|
||
while not release.is_set():
|
||
await asyncio.sleep(0.01)
|
||
|
||
task = loop.create_task(delayed_close())
|
||
state["task"] = task
|
||
with http_utils._shared_async_transports_lock:
|
||
http_utils._pending_eviction_tasks.add(task)
|
||
task.add_done_callback(http_utils._discard_pending_eviction_task)
|
||
ready.set()
|
||
try:
|
||
loop.run_until_complete(task)
|
||
loop.run_until_complete(asyncio.sleep(0))
|
||
except BaseException as err:
|
||
failures.append(err)
|
||
finally:
|
||
with http_utils._shared_async_transports_lock:
|
||
http_utils._pending_eviction_tasks.discard(task)
|
||
loop.close()
|
||
|
||
thread = threading.Thread(target=run_foreign_loop)
|
||
thread.start()
|
||
try:
|
||
assert ready.wait(timeout=2)
|
||
asyncio.run(http_utils.aclose_shared_async_transports())
|
||
assert thread.is_alive()
|
||
assert not state["task"].done()
|
||
finally:
|
||
release.set()
|
||
thread.join(timeout=2)
|
||
|
||
assert not thread.is_alive()
|
||
assert not failures
|