219 lines
7.9 KiB
Python
219 lines
7.9 KiB
Python
"""Tests that server.aclose() is always called during shutdown, even when
|
|
server.drain() raises TimeoutError.
|
|
|
|
Previously, _run_worker (cli.py) did not catch TimeoutError from drain(),
|
|
which meant aclose() was skipped — stuck child processes were never killed
|
|
and became orphaned until the pod was force-terminated.
|
|
|
|
The drain() docstring explicitly documents this behavior:
|
|
"When timeout isn't None, it will raise asyncio.TimeoutError if the
|
|
processes didn't finish in time."
|
|
|
|
The fix catches asyncio.TimeoutError around drain() so aclose() always runs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import multiprocessing as mp
|
|
import socket
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from livekit.agents.cli.cli import _ExitCli, _run_worker
|
|
from livekit.agents.cli.proto import CliArgs
|
|
from livekit.agents.ipc.supervised_proc import SupervisedProc, SupervisedProcKind
|
|
from livekit.agents.utils import aio
|
|
from livekit.agents.worker import AgentServer
|
|
|
|
pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent]
|
|
|
|
_CLI_ARGS = CliArgs(log_level="ERROR", url=None, api_key=None, api_secret=None)
|
|
|
|
|
|
def _make_server(drain_timeout: int = 1) -> AgentServer:
|
|
server = AgentServer(drain_timeout=drain_timeout)
|
|
return server
|
|
|
|
|
|
class _DummySupervisedProc(SupervisedProc):
|
|
@property
|
|
def process_kind(self) -> SupervisedProcKind:
|
|
return SupervisedProcKind.JOB
|
|
|
|
def _create_process(self, cch: socket.socket, log_cch: socket.socket) -> mp.Process:
|
|
raise NotImplementedError
|
|
|
|
async def _main_task(self, ipc_ch: aio.ChanReceiver[object]) -> None:
|
|
raise NotImplementedError
|
|
|
|
|
|
def _make_supervised_proc() -> _DummySupervisedProc:
|
|
return _DummySupervisedProc(
|
|
initialize_timeout=1.0,
|
|
close_timeout=1.0,
|
|
memory_warn_mb=0.0,
|
|
memory_limit_mb=0.0,
|
|
ping_interval=1.0,
|
|
ping_timeout=1.0,
|
|
high_ping_threshold=1.0,
|
|
http_proxy=None,
|
|
mp_ctx=mp.get_context("spawn"),
|
|
loop=asyncio.get_event_loop(),
|
|
)
|
|
|
|
|
|
class TestDrainTimeout:
|
|
"""Verify that aclose() is called regardless of drain() outcome."""
|
|
|
|
def test_aclose_called_when_drain_succeeds(self) -> None:
|
|
"""Baseline: when drain completes normally, aclose IS called."""
|
|
server = _make_server()
|
|
|
|
with (
|
|
patch.object(server, "drain", new_callable=AsyncMock) as mock_drain,
|
|
patch.object(server, "aclose", new_callable=AsyncMock) as mock_aclose,
|
|
patch.object(server, "run", new_callable=AsyncMock),
|
|
):
|
|
_run_worker(server, args=_CLI_ARGS)
|
|
|
|
mock_drain.assert_awaited_once()
|
|
mock_aclose.assert_awaited_once()
|
|
|
|
def test_aclose_called_when_drain_times_out(self) -> None:
|
|
"""When drain raises TimeoutError, aclose must still be called.
|
|
|
|
The TimeoutError originates from asyncio.wait_for() inside
|
|
AgentServer.drain() (worker.py) when a child process doesn't
|
|
exit within drain_timeout seconds. Without catching it,
|
|
stuck child processes are never sent ShutdownRequest and never
|
|
SIGKILL'd — they become orphaned until the pod is force-killed.
|
|
"""
|
|
server = _make_server()
|
|
|
|
with (
|
|
patch.object(
|
|
server,
|
|
"drain",
|
|
new_callable=AsyncMock,
|
|
side_effect=asyncio.TimeoutError("drain timed out"),
|
|
),
|
|
patch.object(server, "aclose", new_callable=AsyncMock) as mock_aclose,
|
|
patch.object(server, "run", new_callable=AsyncMock),
|
|
):
|
|
_run_worker(server, args=_CLI_ARGS)
|
|
|
|
mock_aclose.assert_awaited_once()
|
|
|
|
def test_drain_raises_timeout_from_stuck_process(self) -> None:
|
|
"""Exercises real AgentServer.drain() with a stuck process,
|
|
confirming TimeoutError originates from asyncio.wait_for in
|
|
worker.py.
|
|
"""
|
|
server = _make_server(drain_timeout=1)
|
|
|
|
# Set up internal state that drain() needs, without calling run()
|
|
server._draining = False
|
|
server._lock = asyncio.Lock()
|
|
server._job_lifecycle_tasks = set[asyncio.Task[object]]()
|
|
|
|
# Create a fake proc whose join() never completes (stuck process)
|
|
stuck_future: asyncio.Future[None] = asyncio.Future()
|
|
|
|
class StuckProc:
|
|
running_job = True # drain checks this to decide whether to wait
|
|
|
|
async def join(self) -> None:
|
|
await stuck_future # never resolves
|
|
|
|
class FakeProcPool:
|
|
processes = [StuckProc()]
|
|
|
|
server._proc_pool = FakeProcPool() # type: ignore[assignment]
|
|
|
|
# Suppress the _update_worker_status call which needs a websocket
|
|
with patch.object(server, "_update_worker_status", new_callable=AsyncMock):
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
asyncio.get_event_loop().run_until_complete(server.drain())
|
|
|
|
def test_aclose_skipped_on_non_exitcli_non_timeout_exception(self) -> None:
|
|
"""Other exceptions from drain() still propagate (only TimeoutError
|
|
is caught by the fix).
|
|
"""
|
|
server = _make_server()
|
|
|
|
with (
|
|
patch.object(
|
|
server,
|
|
"drain",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("unexpected"),
|
|
),
|
|
patch.object(server, "aclose", new_callable=AsyncMock) as mock_aclose,
|
|
patch.object(server, "run", new_callable=AsyncMock),
|
|
):
|
|
with pytest.raises(RuntimeError):
|
|
_run_worker(server, args=_CLI_ARGS)
|
|
|
|
mock_aclose.assert_not_awaited()
|
|
|
|
def test_signal_during_drain_forces_exit(self) -> None:
|
|
"""A second SIGTERM/SIGINT while draining triggers a forceful shutdown.
|
|
|
|
After the worker loop, _run_worker installs a force-exit signal handler
|
|
for SIGINT/SIGTERM, so a signal arriving during drain() calls os._exit(1)
|
|
rather than waiting for a (potentially stuck) drain to finish.
|
|
"""
|
|
import signal as signal_mod
|
|
|
|
server = _make_server()
|
|
|
|
async def _signal_while_draining() -> None:
|
|
# simulate a second Ctrl+C arriving while draining
|
|
handler = signal_mod.getsignal(signal_mod.SIGTERM)
|
|
assert callable(handler)
|
|
handler(signal_mod.SIGTERM, None)
|
|
|
|
with (
|
|
patch.object(
|
|
server,
|
|
"drain",
|
|
new_callable=AsyncMock,
|
|
side_effect=_signal_while_draining,
|
|
),
|
|
patch.object(server, "aclose", new_callable=AsyncMock),
|
|
patch.object(server, "run", new_callable=AsyncMock),
|
|
patch("os._exit") as mock_exit,
|
|
):
|
|
_run_worker(server, args=_CLI_ARGS)
|
|
|
|
mock_exit.assert_called_once_with(1)
|
|
|
|
def test_memory_monitor_does_not_swallow_exitcli(self) -> None:
|
|
"""SIGTERM/SIGINT should not be eaten by broad Exception handlers.
|
|
|
|
Issue #4664 showed _ExitCli being raised from a signal handler while
|
|
_memory_monitor_task() was inside psutil. Because _ExitCli inherited
|
|
from Exception, the blanket ``except Exception`` here swallowed the
|
|
shutdown signal and left the worker running instead of draining.
|
|
"""
|
|
proc = _make_supervised_proc()
|
|
proc._pid = 123
|
|
|
|
async def _fake_sleep(_: float) -> None:
|
|
proc._closing = True
|
|
|
|
with (
|
|
patch(
|
|
"livekit.agents.ipc.supervised_proc.psutil.Process",
|
|
side_effect=_ExitCli(),
|
|
),
|
|
patch("livekit.agents.ipc.supervised_proc.asyncio.sleep", side_effect=_fake_sleep),
|
|
patch("livekit.agents.ipc.supervised_proc.logger.exception") as mock_exception,
|
|
):
|
|
with pytest.raises(_ExitCli):
|
|
asyncio.get_event_loop().run_until_complete(proc._memory_monitor_task())
|
|
|
|
mock_exception.assert_not_called()
|