106 lines
3.8 KiB
Python
106 lines
3.8 KiB
Python
"""Tests for graceful shutdown ordering (AC-M3)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from opensquilla.gateway.boot import GatewayServer
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_drained_before_channel_stop() -> None:
|
|
"""task_runtime.shutdown() must complete before channel_manager.stop_all()."""
|
|
call_order: list[str] = []
|
|
|
|
async def mock_runtime_shutdown(**kwargs: object) -> None:
|
|
call_order.append("task_runtime.shutdown")
|
|
|
|
async def mock_stop_all() -> None:
|
|
call_order.append("channel_manager.stop_all")
|
|
|
|
# Build a minimal GatewayServer with mocked internals
|
|
server = GatewayServer.__new__(GatewayServer)
|
|
server._server = None
|
|
server._task = None
|
|
|
|
# Mock services with a task_runtime that records call order
|
|
mock_services = MagicMock()
|
|
mock_task_runtime = MagicMock()
|
|
mock_task_runtime.shutdown = AsyncMock(side_effect=mock_runtime_shutdown)
|
|
mock_services.task_runtime = mock_task_runtime
|
|
# Make services.close() a no-op so the duplicate shutdown call is harmless
|
|
mock_services.close = AsyncMock()
|
|
server._services = mock_services
|
|
|
|
# Mock channel_manager
|
|
mock_channel_manager = MagicMock()
|
|
mock_channel_manager.stop_all = AsyncMock(side_effect=mock_stop_all)
|
|
server._channel_manager = mock_channel_manager
|
|
|
|
# Patch registry to avoid real WS/broadcast logic
|
|
mock_registry = MagicMock()
|
|
mock_registry.broadcast = AsyncMock()
|
|
mock_registry.all = MagicMock(return_value=[])
|
|
|
|
with patch("opensquilla.gateway.boot.get_registry", return_value=mock_registry):
|
|
await server.close(reason="test")
|
|
|
|
assert call_order[0] == "task_runtime.shutdown", (
|
|
f"Expected task_runtime.shutdown first, got: {call_order}"
|
|
)
|
|
assert call_order[1] == "channel_manager.stop_all", (
|
|
f"Expected channel_manager.stop_all second, got: {call_order}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_stops_server_even_if_teardown_raises() -> None:
|
|
"""A failing teardown step must not leave the serve task pending.
|
|
|
|
close() is now invoked on every shutdown (signal or HTTP), so the serve task
|
|
is typically still running when it runs. If a teardown step (channel stop, WS
|
|
broadcast) raises, the error still propagates to the caller, but the server
|
|
stop + serve-task join + pid-lock release must run first — otherwise the
|
|
uvicorn serve task is leaked ("Task was destroyed but it is pending").
|
|
"""
|
|
server = GatewayServer.__new__(GatewayServer)
|
|
|
|
fake_server = MagicMock()
|
|
fake_server.should_exit = False
|
|
server._server = fake_server
|
|
|
|
async def _serve() -> None:
|
|
return None
|
|
|
|
server._task = asyncio.ensure_future(_serve())
|
|
|
|
mock_services = MagicMock()
|
|
mock_services.task_runtime = None
|
|
mock_services.close = AsyncMock()
|
|
server._services = mock_services
|
|
|
|
# The teardown step raises — close() must still stop the server and release
|
|
# the pid lock (in finally) before the error propagates.
|
|
mock_channel_manager = MagicMock()
|
|
mock_channel_manager.stop_all = AsyncMock(side_effect=RuntimeError("boom"))
|
|
server._channel_manager = mock_channel_manager
|
|
|
|
mock_pid_lock = MagicMock()
|
|
server._pid_lock = mock_pid_lock
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.broadcast = AsyncMock()
|
|
mock_registry.all = MagicMock(return_value=[])
|
|
|
|
with patch("opensquilla.gateway.boot.get_registry", return_value=mock_registry):
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
await server.close(reason="test")
|
|
|
|
assert fake_server.should_exit is True # server stop ran despite the failure
|
|
mock_services.close.assert_awaited_once()
|
|
mock_pid_lock.release.assert_called_once() # pid lock always released
|
|
assert server._task.done() # serve task awaited, not leaked
|