210 lines
8.2 KiB
Python
210 lines
8.2 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|||
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||
|
|
|
||
|
|
"""``DuplexOmniEngine``: the session message surface over the generic engine base."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import queue
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from vllm_omni.engine.duplex.commands import Heartbeat
|
||
|
|
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
|
||
|
|
from vllm_omni.engine.duplex.messages import (
|
||
|
|
CloseDuplexSessionMessage,
|
||
|
|
DuplexControlResultMessage,
|
||
|
|
DuplexSessionCommandMessage,
|
||
|
|
DuplexSessionError,
|
||
|
|
OpenDuplexSessionMessage,
|
||
|
|
ResumeDuplexSessionMessage,
|
||
|
|
TouchDuplexSessionMessage,
|
||
|
|
)
|
||
|
|
from vllm_omni.engine.duplex_omni_engine import DuplexOmniEngine
|
||
|
|
|
||
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeRpcClient:
|
||
|
|
def __init__(self, result: Any) -> None:
|
||
|
|
self.result = result
|
||
|
|
self.calls: list[tuple[tuple[str, str], Any, float | None]] = []
|
||
|
|
#: Control RPCs must block rather than raise a raw queue.Full.
|
||
|
|
self.block_on_submit_flags: list[bool] = []
|
||
|
|
|
||
|
|
def execute(self, key, message, *, timeout=None, timeout_message=None, block_on_submit=False):
|
||
|
|
self.calls.append((key, message, timeout))
|
||
|
|
self.block_on_submit_flags.append(block_on_submit)
|
||
|
|
if isinstance(self.result, BaseException):
|
||
|
|
raise self.result
|
||
|
|
return self.result
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeSyncQueue:
|
||
|
|
def __init__(self, *, full: bool = False) -> None:
|
||
|
|
self.items: list[Any] = []
|
||
|
|
self.full = full
|
||
|
|
|
||
|
|
def put(self, item: Any, timeout: float | None = None) -> None:
|
||
|
|
if self.full:
|
||
|
|
raise queue.Full
|
||
|
|
self.items.append(item)
|
||
|
|
|
||
|
|
|
||
|
|
def _engine(result: Any, *, alive: bool = True, full: bool = False) -> DuplexOmniEngine:
|
||
|
|
engine = object.__new__(DuplexOmniEngine)
|
||
|
|
engine._correlated_rpc_client = _FakeRpcClient(result)
|
||
|
|
engine.request_queue = SimpleNamespace(sync_q=_FakeSyncQueue(full=full))
|
||
|
|
engine.is_alive = lambda: alive
|
||
|
|
engine.plugin = None
|
||
|
|
return engine
|
||
|
|
|
||
|
|
|
||
|
|
def _ok(operation: str, **fields: Any) -> DuplexControlResultMessage:
|
||
|
|
return DuplexControlResultMessage(control_id="c", operation=operation, session_id="sid", ok=True, **fields)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_open_session_runs_the_correlated_rpc_with_the_typed_config() -> None:
|
||
|
|
result = _ok("open", capabilities=DuplexCapabilities(), public_session={"id": "sid"}, lease_generation=0)
|
||
|
|
engine = _engine(result)
|
||
|
|
config = DuplexSessionConfig(model="m")
|
||
|
|
|
||
|
|
returned = await engine.open_session_async("sid", config, timeout=3.0)
|
||
|
|
|
||
|
|
assert returned is result
|
||
|
|
((key, message, timeout),) = engine.rpc_client.calls
|
||
|
|
assert key == ("duplex", message.control_id)
|
||
|
|
assert isinstance(message, OpenDuplexSessionMessage)
|
||
|
|
assert message.session_id == "sid" and message.session_config is config
|
||
|
|
assert timeout == 3.0
|
||
|
|
# A momentarily full request queue is backpressure, not a failed open: the
|
||
|
|
# control RPC blocks rather than raising an untyped queue.Full at the caller.
|
||
|
|
assert engine.rpc_client.block_on_submit_flags == [True]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_resume_and_touch_build_their_messages_without_incarnation() -> None:
|
||
|
|
engine = _engine(_ok("close"))
|
||
|
|
await engine.close_session_async("sid", reason="bye")
|
||
|
|
await engine.resume_session_async("sid", expected_lease_generation=4)
|
||
|
|
await engine.touch_session_async("sid", activity="detach")
|
||
|
|
|
||
|
|
close, resume, touch = (call[1] for call in engine.rpc_client.calls)
|
||
|
|
assert isinstance(close, CloseDuplexSessionMessage) and close.reason == "bye"
|
||
|
|
assert isinstance(resume, ResumeDuplexSessionMessage) and resume.expected_lease_generation == 4
|
||
|
|
assert isinstance(touch, TouchDuplexSessionMessage) and touch.activity == "detach"
|
||
|
|
for message in (close, resume, touch):
|
||
|
|
assert message.session_id == "sid"
|
||
|
|
assert not hasattr(message, "incarnation")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_failed_control_result_maps_to_a_typed_session_error() -> None:
|
||
|
|
engine = _engine(
|
||
|
|
DuplexControlResultMessage(
|
||
|
|
control_id="c",
|
||
|
|
operation="open",
|
||
|
|
session_id="sid",
|
||
|
|
ok=False,
|
||
|
|
error_code="resource_exhausted",
|
||
|
|
error_message="duplex_session_capacity_exhausted: limit=1",
|
||
|
|
error_retryable=True,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
with pytest.raises(DuplexSessionError) as excinfo:
|
||
|
|
await engine.open_session_async("sid", DuplexSessionConfig())
|
||
|
|
assert excinfo.value.code == "resource_exhausted"
|
||
|
|
assert excinfo.value.retryable is True
|
||
|
|
assert excinfo.value.session_id == "sid"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_engine_error_and_timeout_map_to_session_errors() -> None:
|
||
|
|
# The correlated RPC client raises RuntimeError for a closed router or a
|
||
|
|
# terminal orchestrator error; both become a typed engine_error.
|
||
|
|
engine = _engine(RuntimeError("orchestrator exploded"))
|
||
|
|
with pytest.raises(DuplexSessionError, match="orchestrator exploded") as excinfo:
|
||
|
|
await engine.close_session_async("sid")
|
||
|
|
assert excinfo.value.code == "engine_error"
|
||
|
|
|
||
|
|
engine = _engine(TimeoutError("duplex open timed out for session sid"))
|
||
|
|
with pytest.raises(DuplexSessionError) as excinfo:
|
||
|
|
await engine.open_session_async("sid", DuplexSessionConfig())
|
||
|
|
assert excinfo.value.code == "timeout" and excinfo.value.retryable is True
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_submit_command_is_one_way_on_the_request_queue() -> None:
|
||
|
|
engine = _engine(None)
|
||
|
|
command = Heartbeat(event_id="evt-1")
|
||
|
|
|
||
|
|
await engine.submit_command_async("sid", command)
|
||
|
|
|
||
|
|
(message,) = engine.request_queue.sync_q.items
|
||
|
|
assert isinstance(message, DuplexSessionCommandMessage)
|
||
|
|
assert message.session_id == "sid" and message.command is command
|
||
|
|
assert engine.rpc_client.calls == []
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_submit_command_rejects_dead_engine_and_full_queue() -> None:
|
||
|
|
with pytest.raises(DuplexSessionError) as excinfo:
|
||
|
|
await _engine(None, alive=False).submit_command_async("sid", Heartbeat())
|
||
|
|
assert excinfo.value.code == "engine_dead"
|
||
|
|
|
||
|
|
with pytest.raises(DuplexSessionError) as excinfo:
|
||
|
|
await _engine(None, full=True).submit_command_async("sid", Heartbeat())
|
||
|
|
assert excinfo.value.code == "engine_backpressure" and excinfo.value.retryable is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_duplex_capabilities_come_from_the_loaded_plugin() -> None:
|
||
|
|
engine = object.__new__(DuplexOmniEngine)
|
||
|
|
engine.plugin = None
|
||
|
|
with pytest.raises(RuntimeError, match="not loaded"):
|
||
|
|
engine.duplex_capabilities
|
||
|
|
capabilities = DuplexCapabilities(supports_barge_in=False)
|
||
|
|
engine.plugin = SimpleNamespace(capabilities=lambda *, max_sessions: capabilities)
|
||
|
|
engine.duplex_session_config = SimpleNamespace(max_sessions=3)
|
||
|
|
assert engine.duplex_capabilities is capabilities
|
||
|
|
|
||
|
|
|
||
|
|
def test_validate_deployment_requires_a_plugin_and_duplex_session_mode() -> None:
|
||
|
|
engine = object.__new__(DuplexOmniEngine)
|
||
|
|
engine.model = "plain-model"
|
||
|
|
engine.pipeline_config = SimpleNamespace(duplex_plugin=None)
|
||
|
|
engine.deploy_config = SimpleNamespace(session_mode="duplex")
|
||
|
|
with pytest.raises(ValueError, match="not a duplex model"):
|
||
|
|
engine._validate_deployment()
|
||
|
|
|
||
|
|
engine.pipeline_config = SimpleNamespace(duplex_plugin="pkg.mod.Plugin")
|
||
|
|
engine.deploy_config = SimpleNamespace(session_mode="turn")
|
||
|
|
with pytest.raises(ValueError, match="session_mode: duplex"):
|
||
|
|
engine._validate_deployment()
|
||
|
|
|
||
|
|
engine.deploy_config = None
|
||
|
|
with pytest.raises(ValueError, match="none resolved"):
|
||
|
|
engine._validate_deployment()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_async_wrappers_run_the_blocking_calls_off_the_event_loop() -> None:
|
||
|
|
loop = asyncio.get_running_loop()
|
||
|
|
engine = _engine(_ok("touch"))
|
||
|
|
seen: list[bool] = []
|
||
|
|
original = engine.rpc_client.execute
|
||
|
|
|
||
|
|
def execute(*args, **kwargs):
|
||
|
|
# Called from the default executor thread, never on the event loop thread.
|
||
|
|
try:
|
||
|
|
seen.append(asyncio.get_running_loop() is loop)
|
||
|
|
except RuntimeError:
|
||
|
|
seen.append(False)
|
||
|
|
return original(*args, **kwargs)
|
||
|
|
|
||
|
|
engine.rpc_client.execute = execute
|
||
|
|
await engine.touch_session_async("sid", activity="heartbeat")
|
||
|
|
assert seen == [False]
|