# 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