Files
vllm-omni/tests/entrypoints/openai/test_duplex_session_attachment.py
05b794f29b [Full Duplex] Feat: Support Full-Duplex realtime runtime & add MiniCPM-o 4.5 demo (#3907)
Signed-off-by: Sy03 <1370724210@qq.com>
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: StormMelody <22371184@zju.edu.cn>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-07-23 16:45:18 +08:00

388 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import asyncio
import base64
import json
import pytest
from vllm_omni.experimental.fullduplex.openai.session_attachment import (
DuplexEventJournal,
DuplexJournalGapError,
DuplexJournalOverflowError,
DuplexResumeCredential,
DuplexSessionAttachmentRegistry,
InvalidResumeTokenError,
ResumeToken,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _Clock:
def __init__(self) -> None:
self.value = 0.0
def __call__(self) -> float:
return self.value
def advance(self, seconds: float) -> None:
self.value += seconds
def _decoded_token_bytes(token: ResumeToken) -> bytes:
padding = "=" * (-len(token.plaintext) % 4)
return base64.urlsafe_b64decode(token.plaintext + padding)
def test_resume_credential_uses_256_bit_token_digest_and_redacted_repr(monkeypatch) -> None:
token = ResumeToken.generate()
credential = DuplexResumeCredential.from_token(token)
calls = []
import vllm_omni.experimental.fullduplex.openai.session_attachment as attachment_module
original_compare = attachment_module.hmac.compare_digest
def recording_compare(left, right):
calls.append((left, right))
return original_compare(left, right)
monkeypatch.setattr(attachment_module.hmac, "compare_digest", recording_compare)
assert len(_decoded_token_bytes(token)) == 32
assert credential.verify(token.plaintext) is True
assert credential.verify("invalid-token") is False
assert len(calls) == 2
assert token.plaintext not in repr(token)
assert token.plaintext not in repr(credential)
assert not hasattr(credential, "plaintext")
assert len(credential.token_digest) == 32
def test_resume_credential_rotation_revokes_old_token() -> None:
first = ResumeToken.generate()
credential = DuplexResumeCredential.from_token(first)
second = credential.rotate()
assert credential.verify(first.plaintext) is False
assert credential.verify(second.plaintext) is True
assert second.plaintext != first.plaintext
def test_event_journal_sequences_acknowledges_and_replays_exact_payloads() -> None:
clock = _Clock()
journal = DuplexEventJournal(max_bytes=4096, ttl_s=60.0, clock=clock)
first = journal.record({"type": "response.audio.delta", "delta": "AAAA"})
second = journal.record({"type": "response.done", "response_id": "resp-1"})
assert first.sequence == 1
assert second.sequence == 2
assert first.payload["server_event_seq"] == 1
assert second.payload["server_event_seq"] == 2
assert first.encoded_bytes == len(
json.dumps(dict(first.payload), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
)
assert [entry.sequence for entry in journal.replay_after(0)] == [1, 2]
assert journal.acknowledge(1) == 1
assert [entry.sequence for entry in journal.replay_after(1)] == [2]
assert journal.acknowledge(1) == 0
def test_event_journal_ttl_pruning_reports_replay_gap() -> None:
clock = _Clock()
journal = DuplexEventJournal(max_bytes=4096, ttl_s=5.0, clock=clock)
journal.record({"type": "one"})
clock.advance(3.0)
journal.record({"type": "two"})
clock.advance(3.0)
assert journal.prune() == 1
with pytest.raises(DuplexJournalGapError, match="older than retained"):
journal.replay_after(0)
assert [entry.payload["type"] for entry in journal.replay_after(1)] == ["two"]
def test_event_journal_overflow_is_explicit_and_does_not_evict_silently() -> None:
clock = _Clock()
journal = DuplexEventJournal(max_bytes=100, ttl_s=60.0, clock=clock)
first = journal.record({"type": "small", "value": "x"})
with pytest.raises(DuplexJournalOverflowError, match="byte limit"):
journal.record({"type": "large", "value": "x" * 200})
assert journal.overflowed is True
assert journal.retained_bytes == first.encoded_bytes
with pytest.raises(DuplexJournalGapError, match="overflowed"):
journal.replay_after(0)
@pytest.mark.asyncio
async def test_registry_resume_rotates_token_replays_and_atomically_replaces_attachment() -> None:
clock = _Clock()
registry = DuplexSessionAttachmentRegistry(
replay_ttl_s=60.0,
replay_max_bytes_per_session=4096,
clock=clock,
)
sends_a = []
closes_a = []
sends_b = []
closes_b = []
async def send_a(payload):
sends_a.append(payload)
async def close_a(reason):
closes_a.append(reason)
async def send_b(payload):
sends_b.append(payload)
async def close_b(reason):
closes_b.append(reason)
created = await registry.create("sid", incarnation=2, send=send_a, close=close_a)
await registry.send_event("sid", {"type": "event-1"})
await registry.send_event("sid", {"type": "event-2"})
resumed = await registry.resume(
"sid",
incarnation=2,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=1,
send=send_b,
close=close_b,
)
assert resumed.attachment_generation == 2
assert resumed.resume_token.plaintext != created.resume_token.plaintext
assert [entry.sequence for entry in resumed.replay_entries] == [2]
assert resumed.replaced_attachment is not None
assert resumed.replaced_attachment.generation == 1
assert await registry.is_current_attachment("sid", created.attachment_generation) is False
assert await registry.is_current_attachment("sid", resumed.attachment_generation) is True
assert await registry.detach("sid", attachment_generation=1) is False
assert await registry.detach("sid", attachment_generation=2) is True
assert [(payload["type"], payload["server_event_seq"]) for payload in sends_a] == [
("event-1", 1),
("event-2", 2),
]
assert closes_a == [] and sends_b == [] and closes_b == []
@pytest.mark.asyncio
async def test_registry_resume_sends_activation_then_replay_before_new_live_events() -> None:
registry = DuplexSessionAttachmentRegistry(
replay_ttl_s=60.0,
replay_max_bytes_per_session=4096,
)
wire = []
activation_started = asyncio.Event()
release_activation = asyncio.Event()
async def old_send(payload):
del payload
async def close(reason):
del reason
async def new_send(payload):
wire.append(payload["type"])
if payload["type"] == "session.resumed":
activation_started.set()
await release_activation.wait()
created = await registry.create("sid-order", incarnation=0, send=old_send, close=close)
await registry.detach("sid-order", attachment_generation=1)
await registry.send_event("sid-order", {"type": "replayed"})
resume_task = asyncio.create_task(
registry.resume(
"sid-order",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=new_send,
close=close,
activation_payload_factory=lambda _token, _generation: {"type": "session.resumed"},
)
)
await activation_started.wait()
live_task = asyncio.create_task(registry.send_event("sid-order", {"type": "live"}))
release_activation.set()
await asyncio.gather(resume_task, live_task)
assert wire == ["session.resumed", "replayed", "live"]
@pytest.mark.asyncio
async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recovery() -> None:
registry = DuplexSessionAttachmentRegistry(
replay_ttl_s=60.0,
replay_max_bytes_per_session=4096,
)
async def send(payload):
del payload
async def failing_send(payload):
del payload
raise RuntimeError("transport lost before rotated token arrived")
async def close(reason):
del reason
created = await registry.create("sid-recovery", incarnation=0, send=send, close=close)
await registry.detach("sid-recovery", attachment_generation=1)
with pytest.raises(RuntimeError, match="transport lost"):
await registry.resume(
"sid-recovery",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=failing_send,
close=close,
activation_payload_factory=lambda token, generation: {
"type": "session.resumed",
"resume_token": token.plaintext,
"attachment_generation": generation,
},
)
recovered = await registry.resume(
"sid-recovery",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
close=close,
)
assert recovered.attachment_generation == 3
with pytest.raises(InvalidResumeTokenError):
await registry.resume(
"sid-recovery",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
close=close,
)
@pytest.mark.asyncio
async def test_registry_concurrent_resume_allows_exactly_one_rotated_token_winner() -> None:
registry = DuplexSessionAttachmentRegistry(
replay_ttl_s=60.0,
replay_max_bytes_per_session=4096,
)
async def send(payload):
del payload
async def close(reason):
del reason
created = await registry.create("sid-race", incarnation=0, send=send, close=close)
async def attempt():
try:
return await registry.resume(
"sid-race",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
close=close,
)
except InvalidResumeTokenError as exc:
return exc
results = await asyncio.gather(attempt(), attempt())
assert sum(not isinstance(result, Exception) for result in results) == 1
assert sum(isinstance(result, InvalidResumeTokenError) for result in results) == 1
@pytest.mark.asyncio
async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> None:
registry = DuplexSessionAttachmentRegistry(
replay_ttl_s=60.0,
replay_max_bytes_per_session=4096,
)
async def send(payload):
del payload
async def close(reason):
del reason
created_a = await registry.create("sid-a", incarnation=1, send=send, close=close)
created_b = await registry.create("sid-b", incarnation=4, send=send, close=close)
await registry.detach("sid-a", attachment_generation=1)
await registry.detach("sid-b", attachment_generation=1)
await registry.send_event("sid-a", {"type": "a-only"})
await registry.send_event("sid-b", {"type": "b-only"})
resumed_a = await registry.resume(
"sid-a",
incarnation=1,
resume_token=created_a.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
close=close,
)
resumed_b = await registry.resume(
"sid-b",
incarnation=4,
resume_token=created_b.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
close=close,
)
assert [entry.payload["type"] for entry in resumed_a.replay_entries] == ["a-only"]
assert [entry.payload["type"] for entry in resumed_b.replay_entries] == ["b-only"]
with pytest.raises(ValueError, match="incarnation mismatch"):
await registry.resume(
"sid-a",
incarnation=2,
resume_token=resumed_a.resume_token.plaintext,
last_received_server_event_seq=1,
send=send,
close=close,
)
@pytest.mark.asyncio
async def test_registry_repr_never_contains_plaintext_tokens() -> None:
registry = DuplexSessionAttachmentRegistry(
replay_ttl_s=60.0,
replay_max_bytes_per_session=4096,
)
async def send(payload):
del payload
async def close(reason):
del reason
created = await registry.create("sid-repr", incarnation=0, send=send, close=close)
await registry.detach("sid-repr", attachment_generation=1)
entry = await registry.send_event(
"sid-repr",
{"type": "session.resumed", "resume_token": created.resume_token.plaintext},
)
assert entry is not None
assert created.resume_token.plaintext not in repr(created)
assert created.resume_token.plaintext not in repr(entry)
assert created.resume_token.plaintext not in repr(registry)