Files
vllm-omni/tests/entrypoints/duplex/test_duplex_serving.py
T
chickeytonandClaude Opus 5 99ff4f3070 [Core][Frontend] Unified Full-duplex Framework (#7413)
Signed-off-by: chickeyton <ngton2014@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:35:36 +08:00

610 lines
25 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The thin websocket handler: handshake, command translation, event pump, resume/takeover."""
from __future__ import annotations
import asyncio
import base64
import json
from contextlib import suppress
from typing import Any
import pytest
from fastapi import WebSocketDisconnect
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex import commands
from vllm_omni.engine.duplex.config import DuplexCapabilities
from vllm_omni.engine.duplex.events import AudioDelta, DuplexEvent, SessionClosed, SessionCreated
from vllm_omni.engine.duplex.messages import DuplexSessionError
from vllm_omni.entrypoints.duplex.realtime_input import RealtimeEnvelope, parse_resume_request
from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler
from vllm_omni.entrypoints.duplex.websocket import MAX_EVENT_BYTES
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_DISCONNECT = object()
class FakeWebSocket:
def __init__(self, query: dict[str, str] | None = None) -> None:
self.query_params = dict(query or {})
self.sent: list[dict[str, Any]] = []
self.accepted = False
self.closed: list[tuple[int, str]] = []
self._send_failure: str | None = None
self._inbound: asyncio.Queue[Any] = asyncio.Queue()
async def accept(self) -> None:
self.accepted = True
async def send_json(self, payload: dict[str, Any]) -> None:
if self.closed or self._send_failure is not None:
raise RuntimeError(
self._send_failure or "Unexpected ASGI message 'websocket.send', after sending 'websocket.close'."
)
self.sent.append(json.loads(json.dumps(payload)))
async def receive_text(self) -> str:
item = await self._inbound.get()
if item is _DISCONNECT:
raise WebSocketDisconnect(code=1000)
return item
async def close(self, code: int = 1000, reason: str = "") -> None:
self.closed.append((code, reason))
# A closed socket ends the reader exactly like a client disconnect.
self._inbound.put_nowait(_DISCONNECT)
# ---- test helpers ----
def feed(self, payload: dict[str, Any] | str) -> None:
self._inbound.put_nowait(payload if isinstance(payload, str) else json.dumps(payload))
def disconnect(self) -> None:
self._inbound.put_nowait(_DISCONNECT)
def break_sends(self) -> None:
"""Kill the write half only: the reader stays parked, as it does in practice."""
self._send_failure = "Unexpected ASGI message 'websocket.send', after sending 'websocket.close'."
def types(self) -> list[str]:
return [payload["type"] for payload in self.sent]
async def wait_for(self, wire_type: str, *, timeout_s: float = 2.0) -> dict[str, Any]:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
for payload in self.sent:
if payload["type"] == wire_type:
return payload
await asyncio.sleep(0.005)
raise AssertionError(f"no {wire_type!r} in {self.types()}")
class FakeHandle:
def __init__(self, session_id: str, capabilities: DuplexCapabilities, *, idle_timeout_s: float = 300) -> None:
self.session_id = session_id
self.capabilities = capabilities
self.public_session: dict[str, Any] = {"id": session_id, "idle_timeout_s": idle_timeout_s}
self.lease_generation = 0
self.closed = False
self.close_reasons: list[str] = []
self.commands: list[commands.DuplexCommand] = []
self._outbox: asyncio.Queue[DuplexEvent] = asyncio.Queue()
def deliver(self, event: DuplexEvent) -> None:
self._outbox.put_nowait(event)
async def submit(self, command: commands.DuplexCommand) -> None:
if self.closed:
raise DuplexSessionError("closed", code="session_closed", session_id=self.session_id)
self.commands.append(command)
async def events(self):
while True:
event = await self._outbox.get()
yield event
if event.is_terminal:
return
async def close(self, *, reason: str = "client_close", timeout: float | None = None) -> None:
self.close_reasons.append(reason)
if self.closed:
return
self.closed = True
self.deliver(SessionClosed(session_id=self.session_id, reason=reason))
class FakeOmni:
def __init__(
self, *, resumable: bool = True, replay_max_bytes: int = 64 * 1024, idle_timeout_s: float = 300
) -> None:
self.duplex_session_config = DuplexSessionRuntimeConfig(
resume_replay_ttl_s=60.0, resume_replay_max_bytes_per_session=replay_max_bytes
)
self.capabilities = DuplexCapabilities(supports_session_resume=resumable)
self.idle_timeout_s = idle_timeout_s
self.opened: list[dict[str, Any]] = []
self.handles: dict[str, FakeHandle] = {}
self.resumed: list[tuple[str, int]] = []
self.detached: list[str] = []
self.open_error: DuplexSessionError | None = None
async def open_session(self, config: Any) -> FakeHandle:
self.opened.append(dict(config))
if self.open_error is not None:
raise self.open_error
session_id = f"duplex-{len(self.handles) + 1:032x}"
handle = FakeHandle(session_id, self.capabilities, idle_timeout_s=self.idle_timeout_s)
self.handles[session_id] = handle
handle.deliver(SessionCreated(session_id=session_id, session={"id": session_id, "model": config.get("model")}))
return handle
def get_session(self, session_id: str) -> FakeHandle | None:
return self.handles.get(session_id)
async def resume_session(self, session_id: str, *, expected_lease_generation: int) -> FakeHandle:
self.resumed.append((session_id, expected_lease_generation))
handle = self.handles[session_id]
handle.lease_generation = expected_lease_generation + 1
return handle
async def detach_session(self, session_id: str) -> None:
self.detached.append(session_id)
def _handler(omni: FakeOmni, **kwargs: Any) -> OmniDuplexSessionHandler:
kwargs.setdefault("config_timeout_s", 1.0)
kwargs.setdefault("idle_timeout_s", 5.0)
return OmniDuplexSessionHandler(duplex_omni=omni, **kwargs)
def _session_update(**session: Any) -> dict[str, Any]:
return {"type": "session.update", "session": {"model": "test-model", "modalities": ["audio", "text"], **session}}
async def _open(
handler: OmniDuplexSessionHandler, omni: FakeOmni, **session: Any
) -> tuple[FakeWebSocket, FakeHandle, asyncio.Task]:
ws = FakeWebSocket({"duplex": "1", "autostart": "0"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed(_session_update(**session))
created = await ws.wait_for("session.created")
handle = omni.handles[created["session"]["id"]]
return ws, handle, task
# --------------------------------------------------------------------------- #
# Handshake and commands #
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_session_update_opens_a_session_and_announces_the_server_id() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni, session_id="mine", instructions="hi")
assert ws.accepted
created = ws.sent[0]
assert created["type"] == "session.created"
assert created["session"]["id"] == handle.session_id != "mine"
assert created["attachment_generation"] == 1
assert isinstance(created["resume_token"], str) and created["resume_token"]
assert "incarnation" not in created
assert "server_event_seq" not in created
# The whole session object went to open_session; the id inside it is ignored there.
assert omni.opened == [
{"model": "test-model", "modalities": ["audio", "text"], "session_id": "mine", "instructions": "hi"}
]
ws.feed({"type": "input_audio_buffer.append", "audio": base64.b64encode(b"\x00\x01" * 8).decode("ascii")})
ws.feed({"type": "input_audio_buffer.commit", "event_id": "evt-c"})
await asyncio.sleep(0.05)
assert [type(command) for command in handle.commands] == [commands.AppendAudio, commands.Commit]
assert handle.commands[1].event_id == "evt-c"
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
# A resumable session is detached (engine-owned grace), not closed.
assert omni.detached == [handle.session_id]
assert handle.close_reasons == []
@pytest.mark.asyncio
async def test_session_events_are_journaled_and_sent_in_order() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
delta = await ws.wait_for("response.output_audio.delta")
assert delta["delta"] == "aGk=" and delta["server_event_seq"] == 1
ws.feed({"type": "session.event_ack", "server_event_seq": 1})
ws.feed({"type": "session.event_ack", "server_event_seq": 9, "event_id": "evt-ack"})
error = await ws.wait_for("error")
assert error["error"]["code"] == "invalid_event_ack" and error["error"]["event_id"] == "evt-ack"
await handle.close(reason="client_close")
closed = await ws.wait_for("session.closed")
assert closed["reason"] == "client_close"
await asyncio.wait_for(task, timeout=2.0)
assert ws.closed[0][1] == "client_close"
@pytest.mark.asyncio
async def test_envelope_errors_are_answered_locally_without_touching_the_session() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
ws.feed("not json")
ws.feed("x" * (MAX_EVENT_BYTES + 1))
ws.feed({"type": "totally.unknown", "event_id": "evt-u"})
ws.feed({"type": "session.resume", "event_id": "evt-r"})
ws.feed({"type": "playback.ack", "event_id": "evt-p"}) # played_ms missing
await asyncio.sleep(0.1)
codes = [payload["error"]["code"] for payload in ws.sent if payload["type"] == "error"]
assert codes == ["invalid_json", "event_too_large", "unknown_event", "unsupported_session_resume", "bad_event"]
errors = [payload for payload in ws.sent if payload["type"] == "error"]
assert errors[2]["error"]["event_id"] == "evt-u"
assert errors[4]["error"]["event_id"] == "evt-p"
assert handle.commands == []
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
@pytest.mark.asyncio
async def test_open_rejection_is_reported_and_the_socket_ends() -> None:
omni = FakeOmni()
omni.open_error = DuplexSessionError("no room", code="resource_exhausted", retryable=True)
handler = _handler(omni)
ws = FakeWebSocket({"duplex": "1"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed(_session_update())
await asyncio.wait_for(task, timeout=2.0)
assert ws.types() == ["error"]
assert ws.sent[0]["error"]["code"] == "resource_exhausted"
@pytest.mark.asyncio
async def test_config_timeout_and_first_message_validation() -> None:
omni = FakeOmni()
handler = _handler(omni, config_timeout_s=0.05)
ws = FakeWebSocket({"duplex": "1"})
await asyncio.wait_for(handler.handle_realtime_session(ws), timeout=2.0)
assert ws.sent[0]["error"]["code"] == "config_timeout"
ws = FakeWebSocket({"duplex": "1"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed("{not json")
await asyncio.wait_for(task, timeout=2.0)
assert ws.sent[0]["error"]["code"] == "invalid_json"
assert omni.opened == []
@pytest.mark.asyncio
async def test_idle_timeout_detaches_a_resumable_session_like_a_disconnect() -> None:
omni = FakeOmni(idle_timeout_s=0.05)
handler = _handler(omni, idle_timeout_s=0.05)
ws, handle, task = await _open(handler, omni)
await asyncio.wait_for(task, timeout=2.0)
# Serving makes no session-lifetime decision: the engine lease decides
# whether a silent, detached session expires.
assert handle.close_reasons == []
assert omni.detached == [handle.session_id]
@pytest.mark.asyncio
async def test_idle_timeout_closes_a_non_resumable_session() -> None:
omni = FakeOmni(idle_timeout_s=0.05, resumable=False)
handler = _handler(omni, idle_timeout_s=0.05)
ws, handle, task = await _open(handler, omni)
await asyncio.wait_for(task, timeout=2.0)
assert handle.close_reasons == ["disconnect"]
assert omni.detached == []
@pytest.mark.asyncio
async def test_transport_send_failure_detaches_the_session_instead_of_closing_it() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
# Only the write half dies, so the pump's send is the one and only report
# of the broken socket (the reader is still parked on receive).
ws.break_sends()
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
await asyncio.sleep(0.05)
# The engine session is alive and resumable, so this is a disconnect of
# the current attachment, not a session close; the pump keeps journaling.
assert handle.close_reasons == []
assert omni.detached == [handle.session_id]
assert not handler._pumps[handle.session_id].done()
# The reader sees the same broken socket a moment later. The attachment is
# already gone, so this must not detach a second time: another detach would
# restart the engine's disconnect grace window.
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
assert handle.close_reasons == []
handle.deliver(SessionClosed(session_id=handle.session_id, reason="client_close"))
await asyncio.sleep(0.05)
assert handle.session_id not in handler._pumps
@pytest.mark.asyncio
async def test_non_resumable_session_is_closed_on_disconnect() -> None:
omni = FakeOmni(resumable=False)
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
assert "resume_token" not in ws.sent[0]
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert handle.close_reasons == ["disconnect"]
assert omni.detached == []
@pytest.mark.asyncio
async def test_a_client_close_still_delivers_session_closed_before_the_socket_goes() -> None:
"""The read loop must not outrun the pump's terminal event.
``DuplexSessionHandle._deliver`` queues ``SessionClosed`` and marks the
handle closed in one synchronous step, so ``handle.closed`` is already true
while the event is still sitting in the outbox. ``_read_loop`` loops on
exactly that flag: it returned, the endpoint returned, and the ASGI server
tore the socket down with ``session.closed`` unsent -- the client saw an
abrupt close (no close frame) instead of the terminal event it was waiting
for.
The teardown is what makes this observable, so it is modelled here: a real
connection stops accepting writes the moment the endpoint returns, which is
why a pump that is merely *still scheduled* is already too late.
"""
omni = FakeOmni()
handler = _handler(omni)
ws = FakeWebSocket({"duplex": "1", "autostart": "0"})
async def serve() -> None:
try:
await handler.handle_realtime_session(ws)
finally:
# The ASGI server drops the connection when the endpoint returns;
# anything the pump writes after this never reaches the client.
ws.break_sends()
task = asyncio.create_task(serve())
ws.feed(_session_update())
created = await ws.wait_for("session.created")
handle = omni.handles[created["session"]["id"]]
submit = handle.submit
async def closing_submit(command: commands.DuplexCommand) -> None:
await submit(command)
if isinstance(command, commands.CloseSession):
# Same order as the real handle: queue the event, then flip the flag.
handle.deliver(SessionClosed(session_id=handle.session_id, reason="client_close"))
handle.closed = True
handle.submit = closing_submit # type: ignore[method-assign]
ws.feed({"type": "session.close"})
await asyncio.wait_for(task, timeout=2.0)
assert ws.types()[-1] == "session.closed", f"terminal event never reached the wire: {ws.types()}"
assert ws.closed and ws.closed[0][0] == 1000, "the socket must close with a normal close frame"
# --------------------------------------------------------------------------- #
# Resume and takeover #
# --------------------------------------------------------------------------- #
async def _resume(
handler: OmniDuplexSessionHandler,
session_id: str,
token: str,
*,
last_seq: int = 0,
) -> tuple[FakeWebSocket, asyncio.Task]:
ws = FakeWebSocket({"duplex": "1", "resume": "1"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed(
{
"type": "session.resume",
"session_id": session_id,
"resume_token": token,
"last_received_server_event_seq": last_seq,
}
)
return ws, task
@pytest.mark.asyncio
async def test_resume_after_disconnect_replays_missed_events_and_rotates_the_token() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="one"))
await ws.wait_for("response.output_audio.delta")
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
# Emitted while detached: journaled for replay only.
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="two"))
await asyncio.sleep(0.05)
ws2, task2 = await _resume(handler, handle.session_id, token, last_seq=1)
resumed = await ws2.wait_for("session.resumed")
assert resumed["session_id"] == handle.session_id
assert resumed["attachment_generation"] == 2
assert resumed["resume_token"] != token
assert "incarnation" not in resumed
replayed = await ws2.wait_for("response.output_audio.delta")
assert replayed["delta"] == "two" and replayed["server_event_seq"] == 2
assert omni.resumed == [(handle.session_id, 0)]
# The old token is revoked by the rotation.
ws3, task3 = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(task3, timeout=2.0)
assert ws3.sent[0]["error"]["code"] == "invalid_resume_token"
ws2.feed({"type": "session.close"})
await asyncio.sleep(0.05)
ws2.disconnect()
await asyncio.wait_for(task2, timeout=2.0)
@pytest.mark.asyncio
async def test_resume_takes_over_a_live_attachment() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
ws2, task2 = await _resume(handler, handle.session_id, token)
await ws2.wait_for("session.resumed")
replaced = await ws.wait_for("session.replaced")
assert replaced["attachment_generation"] == 1
assert ws.closed and ws.closed[0][1] == "session_replaced"
# The replaced socket's later input is ignored; the new one drives the session.
ws.feed({"type": "input_audio_buffer.clear"})
ws2.feed({"type": "input_audio_buffer.clear", "event_id": "evt-new"})
await asyncio.sleep(0.05)
assert [command.event_id for command in handle.commands] == ["evt-new"]
await asyncio.wait_for(task, timeout=2.0)
ws2.disconnect()
await asyncio.wait_for(task2, timeout=2.0)
assert omni.detached == [handle.session_id]
@pytest.mark.asyncio
async def test_resume_validation_errors() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws = FakeWebSocket({"duplex": "1", "resume": "1"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed({"type": "session.resume", "session_id": "duplex-x"})
await asyncio.wait_for(task, timeout=2.0)
assert ws.sent[0]["error"]["code"] == "invalid_session_resume"
ws, task = await _resume(handler, "duplex-unknown", "token")
await asyncio.wait_for(task, timeout=2.0)
assert ws.sent[0]["error"]["code"] == "session_resume_expired"
@pytest.mark.asyncio
async def test_journal_overflow_degrades_to_live_delivery_with_resync_required() -> None:
omni = FakeOmni(replay_max_bytes=256)
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="x" * 400))
resync = await ws.wait_for("session.resync_required")
assert resync["reason"] == "journal_overflow"
delta = await ws.wait_for("response.output_audio.delta")
assert "server_event_seq" not in delta
assert ws.types().index("session.resync_required") < ws.types().index("response.output_audio.delta")
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
# --------------------------------------------------------------------------- #
# Envelope helpers #
# --------------------------------------------------------------------------- #
def test_realtime_envelope_query_rules() -> None:
envelope = RealtimeEnvelope.from_query_params({"model": "m"})
assert envelope.initial_open_payload() == {"model": "m"}
assert envelope.initial_open_payload() is None # autostart happens once
envelope = RealtimeEnvelope.from_query_params({"model": "m", "autostart": "0"})
assert envelope.resume_only is True
assert envelope.initial_open_payload() is None
assert RealtimeEnvelope.from_query_params({"resume": "1"}).resume_only is True
assert RealtimeEnvelope.from_query_params({"session_id": "mine"}).default_session_payload() == {"model": None}
def test_realtime_envelope_first_message_classification() -> None:
envelope = RealtimeEnvelope.from_query_params({"model": "m", "autostart": "0"})
resume = envelope.first_message({"type": "session.resume", "session_id": "s"})
assert resume.kind == "resume" and resume.resume_payload["session_id"] == "s"
envelope = RealtimeEnvelope.from_query_params({"model": "m", "autostart": "0"})
opened = envelope.first_message({"type": "session.update", "session": {"model": "x", "instructions": "hi"}})
assert opened.kind == "open" and opened.session_payload == {"model": "x", "instructions": "hi"}
assert opened.pending_command_payload is None
envelope = RealtimeEnvelope.from_query_params({"model": "m", "autostart": "0"})
autostarted = envelope.first_message({"type": "input_audio_buffer.commit"})
assert autostarted.kind == "open" and autostarted.session_payload == {"model": "m"}
assert autostarted.pending_command_payload == {"type": "input_audio_buffer.commit"}
def test_parse_resume_request_requires_the_three_fields_only() -> None:
request = parse_resume_request({"session_id": "s", "resume_token": "t", "last_received_server_event_seq": 3})
assert request is not None
assert (request.session_id, request.resume_token, request.last_received_server_event_seq) == ("s", "t", 3)
assert parse_resume_request({"session_id": "s", "resume_token": "t"}).last_received_server_event_seq == 0
assert parse_resume_request({"session_id": "s", "resume_token": "t", "incarnation": 1}) is not None
assert parse_resume_request({"session_id": "s"}) is None
assert parse_resume_request({"session_id": "s", "resume_token": "t", "last_received_server_event_seq": -1}) is None
@pytest.mark.asyncio
async def test_a_resume_that_fails_to_activate_does_not_detach_the_live_attachment() -> None:
"""A rejected resume must roll back only what it owns.
Two reconnects can both authenticate and both complete the engine resume;
only one activates. The loser's activation raises, and the rollback used to
detach the *session*, starting the engine's disconnect grace for the winner
-- a grace ordinary heartbeats do not clear.
The activation failure is injected rather than raced for: what is under test
is the rollback's precondition, not the window that produces it.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
try:
assert await registry.has_attachment(handle.session_id), "the opener is attached"
detached_before = list(omni.detached)
async def failing_resume(*args, **kwargs):
raise RuntimeError("transport activation lost the race")
original_resume = registry.resume
registry.resume = failing_resume # type: ignore[method-assign]
try:
ws_lose, task_lose = await _resume(handler, handle.session_id, token)
error = await ws_lose.wait_for("error")
finally:
registry.resume = original_resume # type: ignore[method-assign]
assert error["error"]["code"] == "session_resume_conflict"
assert omni.detached == detached_before, "the loser must not detach the winner"
assert await registry.has_attachment(handle.session_id), "the winner is still attached"
finally:
for pending in (task, locals().get("task_lose")):
if pending is not None:
pending.cancel()
with suppress(asyncio.CancelledError, Exception):
await pending