[Model][PersonaPlex] Add paced multi-session validation (#7428)

Signed-off-by: luozijian <luozijian0924@gmail.com>
This commit is contained in:
LOGO127 authored and GitHub committed 2026-09-16 23:04:37 -07:00
1 parent 4200768f59
commit 224152566a
3 files changed
+1155 -88

No files matched your search

+108
View File
@@ -271,3 +271,111 @@ The required evidence is:
Audio that is empty, all zero, non-finite, or only a protocol `listen` event is
not a successful end-to-end result.
### Paced multi-session load driver
The existing driver keeps its two-session admission and slot-reuse validation
when `--sessions` is omitted. Passing `--sessions N` selects a separate,
bounded load mode; it does not replace the lifecycle test or change server
admission capacity. Start an existing PersonaPlex server configured to admit
the requested number of sessions, then run from a source checkout with the
vLLM-Omni client dependencies installed:
```bash
python -m tests.e2e.online_serving.personaplex_realtime_duplex \
--model nvidia/personaplex-7b-v1 \
--input-wav /path/to/input.wav \
--sessions 2 --load-frames 1000 --drain-s 2 \
--server-revision SERVER_COMMIT --server-hardware A100-80GB \
--output-dir /tmp/personaplex-load-n2
```
Use a new output directory for every run. The driver refuses to overwrite an
existing `load-result.json`. The server labels are supplied by the operator,
not detected or verified by the client. Record the server command, package
versions, checkpoint revision and hardware alongside a published measurement.
All attempted sessions finish admission before admitted sessions share a
common input start. A rejected session remains a failed row in the report;
it is not removed from the denominator. Each session receives at most
`--load-frames` input frames, without looping the WAV, and uses 80 ms pacing.
A stalled send cannot cause a catch-up burst. The timeline records send
schedule lateness so an overloaded client is visible rather than mistaken
for a faster server. This mode measures steady concurrency, not join/leave
interference; the original lifecycle mode still checks slot reuse.
Load mode does not probe an additional N+1 session for `resource_exhausted`;
the lifecycle mode owns that admission-limit check. A per-session
`client_pacing_warning` and the summary's `client_pacing_warning_sessions`
identify send-lateness p99 above one nominal frame (80 ms). This is a warning,
not an extra acceptance gate: read it alongside server-facing measurements
so a slow client is not mistaken for a slow server.
The fixed drain window is part of the measurement configuration. Audio
arriving during cleanup is excluded, so closing cannot rescue a session that
missed its observation deadline. Cleanup is attempted for admitted sessions
even when audio validation fails. Protocol errors, missing/invalid audio,
shared response IDs and cleanup failures make the report fail and the CLI
exit nonzero. Cancellation closes transports and propagates to the caller.
`--cleanup-timeout-s` (default 5 s) bounds session-close send and acknowledgement
together, independently of `--timeout-s`. The subsequent WebSocket close
handshake has the same separate bound; an unresponsive close acknowledgement
cannot hold cancellation for the full inference timeout.
`load-result.json` retains per-session send and audio-packet timelines using
the client's monotonic clock. It does not export handshake credentials or
raw audio payloads. Metric definitions are explicit:
Both modes validate each nonempty audio packet's strict base64 encoding, PCM16
sample alignment, 24 kHz sample rate and response identity. Valid aggregate
audio cannot hide a malformed packet. In load mode, rejected packets make the session fail
and appear in `invalid_audio_packets` with their event index, receive time and
fixed diagnostic code; they do not contribute samples or timing intervals.
Empty audio deltas remain legal and contribute no samples. This check does
not change the public client's decoding. The lifecycle scenario and dispatch
remain the same, but malformed packets that aggregate checks once tolerated
now fail that mode as well.
The probe accepts both the current OpenAI Realtime audio event name
`response.output_audio.delta` and the legacy `response.audio.delta`. Validation
uses the wire payload directly rather than relying on a particular client
library's alias table, so a server-side event-name migration cannot silently
turn real audio into a zero-output measurement.
| Field | Meaning |
| --- | --- |
| `client_audio_packet_interval_ms` | Consecutive nonempty packet arrivals: median, linear p99, max, and zero-based index of the first maximal interval. Interval index 0 ends at packet 1. One packet can contain multiple codec frames; this is not server tick latency. |
| `client_first_audio_after_stream_start_ms` | First audio receipt minus first input send start; admission is excluded. |
| `client_stream_rtf` | First input send start to last audio receipt, divided by received audio duration. This includes real-time input pacing and is not an inference-only RTF. |
| `client_send_lateness_ms` | Send start minus its original shared schedule, clipped at zero. |
| `frame_deficit` | Integer input-frame minus output-frame count from the shared session validation; missing when valid whole-frame accounting is unavailable. |
| `output_frames`, `voiced_frames`, `silent_frames`, `frame_coverage_ratio`, `audio_rms`, `audio_chunks` | Session-validation statistics retained on success and on acceptance failure once computed. |
| `acceptance_check` | Failed local check, with numeric diagnostics in `error`; other exceptions retain phase/type only, without remote handshake or metadata text. |
| `audio_underrun_s`, `audio_underrun_event_count`, `audio_continuity_ok` | Shared `compute_continuity_stats` on valid PCM16 packet arrivals, with its default 100 ms underrun threshold; absent audio gives missing values rather than a continuity pass. |
No output gives missing latency/RTF values, not zero. By default RTF is
diagnostic; `--max-client-rtf` adds an explicit ceiling. The existing frame
coverage, audible-frame and scheduler-data-plane checks still apply. A longer
`--drain-s` can demonstrate eventual completion under backlog, but it must not
be reported as real-time capacity when the fixed-window run failed or client
RTF exceeded the stated target. Likewise, the first live session can pay
one-time model/codec/JIT costs; either use the server's configured duplex
warmup path or label that run as cold-start evidence before comparing steady
capacity. Passing client-only metrics does not certify model quality or a
real-time capacity target on another GPU. Begin with N=1 and N=2; higher N is a
capacity experiment, not a promised property of an A100 or of this patch.
Continuity is a client-arrival diagnostic, not proof of speaker playback and
not an additional pass/fail gate. Packet-interval and send-lateness summaries
retain their documented linear p99; the older client interval helper reports
rounded nearest-rank p95 instead and is not substituted for that schema.
Deterministic driver and localhost WebSocket checks need no model or GPU:
```bash
python -m pytest tests/e2e/online_serving/test_personaplex_load_driver.py \
-m 'core_model and cpu' --run-level core_model -q
```
These checks test the driver against controlled protocol responses. Run the
load-driver command against real weights separately before reporting model-serving
performance. Server tick timestamps and generic TTFT/TPOT aggregation remain
the responsibility of the existing duplex metrics paths, not this driver.
@@ -10,24 +10,24 @@ import base64
import hashlib
import json
import math
import time
import uuid
import wave
from collections.abc import Sequence
from collections.abc import Awaitable, Callable, Sequence
from pathlib import Path
from typing import Protocol
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import numpy as np
try:
import websockets
from websockets.exceptions import ConnectionClosed
from websockets.exceptions import ConnectionClosed, WebSocketException
except ImportError as exc: # pragma: no cover - driver dependency.
raise SystemExit("Install websockets first: pip install websockets") from exc
from vllm_omni.clients.duplex import (
EventCollector,
write_pcm16_wav,
)
from vllm_omni.benchmarks.audio_continuity import compute_continuity_stats
from vllm_omni.clients.duplex import EventCollector, write_pcm16_wav
from vllm_omni.clients.duplex import (
wait_for_condition as wait_for,
)
@@ -35,6 +35,24 @@ from vllm_omni.clients.duplex import (
SAMPLE_RATE_HZ = 24_000
FRAME_SAMPLES = 1_920
FRAME_PERIOD_S = FRAME_SAMPLES / SAMPLE_RATE_HZ
AUDIO_DELTA_EVENT_TYPES = frozenset({"response.audio.delta", "response.output_audio.delta"})
class _AudioAcceptanceError(AssertionError):
"""A local acceptance failure with numeric diagnostics safe to report."""
def __init__(self, check: str, message: str, stats: dict[str, object]) -> None:
super().__init__(message)
self.check = check
self.stats = dict(stats)
class _ProbeTransport(Protocol):
async def recv(self) -> str | bytes: ...
async def send(self, message: str) -> None: ...
async def close(self) -> None: ...
class RawRealtimeProbe:
@@ -46,29 +64,33 @@ class RawRealtimeProbe:
keeps a transport-only client and reads everything from ``.events``.
"""
def __init__(self, url: str, *, max_size: int = 64 * 1024 * 1024) -> None:
def __init__(self, url: str, *, max_size: int = 64 * 1024 * 1024, close_timeout_s: float = 10.0) -> None:
self.url = url
self.max_size = max_size
self.close_timeout_s = close_timeout_s
self.events = EventCollector()
self._ws = None
self._ws: _ProbeTransport | None = None
self._reader_task: asyncio.Task[None] | None = None
async def __aenter__(self) -> RawRealtimeProbe:
self._ws = await websockets.connect(self.url, max_size=self.max_size)
self._ws = await websockets.connect(self.url, max_size=self.max_size, close_timeout=self.close_timeout_s)
self._reader_task = asyncio.create_task(self._read_events())
return self
async def __aexit__(self, exc_type, exc, traceback) -> None:
if self._ws is not None:
await self._ws.close()
if self._reader_task is not None:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
try:
if self._ws is not None:
await self._ws.close()
finally:
if self._reader_task is not None:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
async def _read_events(self) -> None:
assert self._ws is not None
try:
while True:
raw = await self._ws.recv()
@@ -81,6 +103,7 @@ class RawRealtimeProbe:
return
async def send(self, event: dict[str, object]) -> None:
assert self._ws is not None
await self._ws.send(json.dumps(event))
@@ -142,6 +165,38 @@ def _events(client: RawRealtimeProbe, event_type: str) -> list[dict[str, object]
return [event for event in client.events.events if event.get("type") == event_type]
def _audio_events(client: RawRealtimeProbe) -> list[dict[str, object]]:
"""Return audio deltas from both current and legacy Realtime event names."""
return [event for event in client.events.events if event.get("type") in AUDIO_DELTA_EVENT_TYPES]
def _validated_audio_event(event: dict[str, object]) -> bytes:
"""Decode one wire audio delta without depending on client event aliases."""
encoded = event.get("delta")
if not isinstance(encoded, str):
raise ValueError("missing_audio_delta")
try:
chunk = base64.b64decode(encoded, validate=True)
except ValueError:
raise ValueError("invalid_audio_base64") from None
if chunk:
if len(chunk) % 2:
raise ValueError("unaligned_pcm16_packet")
if event.get("sample_rate_hz") != SAMPLE_RATE_HZ:
raise ValueError("invalid_audio_sample_rate")
if not isinstance(event.get("response_id"), str) or not event.get("response_id"):
raise ValueError("missing_audio_response_id")
return chunk
def _validated_audio_chunks(client: RawRealtimeProbe) -> list[tuple[dict[str, object], bytes]]:
return [(event, _validated_audio_event(event)) for event in _audio_events(client)]
def _audio_bytes(client: RawRealtimeProbe) -> bytes:
return b"".join(chunk for _, chunk in _validated_audio_chunks(client) if chunk)
async def _open_session(
args: argparse.Namespace,
*,
@@ -151,22 +206,7 @@ async def _open_session(
) -> tuple[RawRealtimeProbe, dict[str, object]]:
client = RawRealtimeProbe(_realtime_url(args.url, args.model, session_id))
await client.__aenter__()
await client.send(
{
"type": "session.update",
"session": {
"session_id": session_id,
"model": args.model,
"modalities": ["audio", "text"],
"input_audio_format": "pcm_f32le",
"output_audio_format": "pcm16",
"voice": args.voice,
"instructions": persona,
"turn_detection": None,
"extra_body": {"auto_response": True},
},
}
)
await client.send(_session_update(args, session_id=session_id, persona=persona))
event_type = "error" if expect_error else "session.created"
await wait_for(
lambda: client.events.count(event_type) > 0,
@@ -176,6 +216,23 @@ async def _open_session(
return client, _events(client, event_type)[-1]
def _session_update(args: argparse.Namespace, *, session_id: str, persona: str) -> dict[str, object]:
return {
"type": "session.update",
"session": {
"session_id": session_id,
"model": args.model,
"modalities": ["audio", "text"],
"input_audio_format": "pcm_f32le",
"output_audio_format": "pcm16",
"voice": args.voice,
"instructions": persona,
"turn_detection": None,
"extra_body": {"auto_response": True},
},
}
async def _close_session(client: RawRealtimeProbe, *, timeout_s: float) -> None:
await client.send({"type": "session.close"})
await wait_for(
@@ -200,23 +257,24 @@ async def _stream_frames(
frame_count = min(frame_count, max_frames)
for seq in range(frame_count):
frame = pcm[seq * FRAME_SAMPLES : (seq + 1) * FRAME_SAMPLES]
frame = np.pad(frame, (0, FRAME_SAMPLES - frame.size))
frame = np.ascontiguousarray(frame, dtype="<f4")
await client.send(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(frame).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": SAMPLE_RATE_HZ,
"duration_ms": 80,
"audio_end_ms": (seq + 1) * 80,
"is_speech": bool(np.sqrt(np.mean(np.square(frame))) >= 1e-4),
}
)
await client.send(_frame_event(frame, seq))
await asyncio.sleep(FRAME_PERIOD_S)
return frame_count
def _frame_event(frame: np.ndarray, seq: int) -> dict[str, object]:
frame = np.ascontiguousarray(np.pad(frame, (0, FRAME_SAMPLES - frame.size)), dtype="<f4")
return {
"type": "input_audio_buffer.append",
"audio": base64.b64encode(frame).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": SAMPLE_RATE_HZ,
"duration_ms": 80,
"audio_end_ms": (seq + 1) * 80,
"is_speech": bool(np.sqrt(np.mean(np.square(frame))) >= 1e-4),
}
def _audio_frame_stats(
raw: bytes,
*,
@@ -242,11 +300,7 @@ def _audio_frame_stats(
def _response_ids(client: RawRealtimeProbe) -> set[str]:
return {
str(event["response_id"])
for event in _events(client, "response.output_audio.delta")
if isinstance(event.get("response_id"), str)
}
return {str(event["response_id"]) for event in _audio_events(client) if isinstance(event.get("response_id"), str)}
def _session_result(
@@ -256,40 +310,66 @@ def _session_result(
args: argparse.Namespace,
minimum_chunks: int,
) -> tuple[bytes, set[str], dict[str, object]]:
chunks = sum(len(items) for items in client.events.response_audio.values())
raw = client.events.audio_bytes()
if chunks < minimum_chunks or not raw or len(raw) % 2:
raise AssertionError(f"invalid PCM16 output: chunks={chunks}, bytes={len(raw)}")
if isinstance(client, RawRealtimeProbe):
audio_chunks = _validated_audio_chunks(client)
chunks = sum(bool(chunk) for _, chunk in audio_chunks)
raw = b"".join(chunk for _, chunk in audio_chunks if chunk)
else:
# Unit fixtures may provide an already-decoded EventCollector without
# retaining the base64 wire field. Real websocket probes always take
# the strict wire-validation path above.
chunks = sum(len(items) for items in client.events.response_audio.values())
raw = client.events.audio_bytes()
stats: dict[str, object] = {"audio_chunks": chunks, "audio_samples": len(raw) // 2}
if not raw or len(raw) % 2:
raise _AudioAcceptanceError("pcm16_output", f"invalid PCM16 output: chunks={chunks}, bytes={len(raw)}", stats)
pcm = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
rms = float(np.sqrt(np.mean(np.square(pcm))))
stats["audio_rms"] = rms
# This low floor only catches corrupt, non-finite, or effectively zero PCM.
# Audible speech is checked per frame below with the higher 1e-3 default.
if not np.isfinite(pcm).all() or rms <= 1e-5:
raise AssertionError(f"output audio is non-finite or silent: samples={pcm.size}, rms={rms}")
rates = {
int(event["sample_rate_hz"])
for event in _events(client, "response.output_audio.delta")
if isinstance(event.get("sample_rate_hz"), int)
}
raise _AudioAcceptanceError(
"audio_rms", f"output audio is non-finite or silent: samples={pcm.size}, rms={rms}", stats
)
rates = {rate for event in _audio_events(client) if isinstance(rate := event.get("sample_rate_hz"), int)}
if rates != {SAMPLE_RATE_HZ}:
raise AssertionError(f"unexpected output sample rates: {sorted(rates)}")
stats = _audio_frame_stats(
raw,
input_frames=input_frames,
voiced_frame_rms_threshold=args.voiced_frame_rms_threshold,
)
if not 0 <= int(stats["frame_deficit"]) <= args.max_frame_deficit:
raise AssertionError(f"invalid synchronous frame accounting: input={input_frames}, stats={stats}")
raise _AudioAcceptanceError("sample_rate", f"unexpected output sample rates: {sorted(rates)}", stats)
if input_frames < 1:
raise _AudioAcceptanceError("input_frames", f"audio received without input frames: input={input_frames}", stats)
try:
frame_stats = _audio_frame_stats(
raw,
input_frames=input_frames,
voiced_frame_rms_threshold=args.voiced_frame_rms_threshold,
)
except AssertionError as exc:
# _audio_frame_stats only formats local sample counts, not wire data.
raise _AudioAcceptanceError("whole_codec_frames", str(exc), stats) from exc
stats.update(frame_stats)
if chunks < minimum_chunks:
raise _AudioAcceptanceError(
"audio_chunks", f"insufficient audio chunks: chunks={chunks}, minimum={minimum_chunks}", stats
)
if not 0 <= int(frame_stats["frame_deficit"]) <= args.max_frame_deficit:
raise _AudioAcceptanceError(
"frame_deficit",
f"invalid synchronous frame accounting: input={input_frames}, maximum_deficit={args.max_frame_deficit}, "
f"stats={stats}",
stats,
)
min_voiced_frames = int(args.min_voiced_frames)
if min_voiced_frames < 1:
raise ValueError(f"min_voiced_frames must be positive, got {min_voiced_frames}")
if int(stats["voiced_frames"]) < min_voiced_frames:
raise AssertionError(
f"output contained insufficient audible speech: minimum_voiced_frames={min_voiced_frames}, stats={stats}"
if int(frame_stats["voiced_frames"]) < min_voiced_frames:
raise _AudioAcceptanceError(
"voiced_frames",
f"output contained insufficient audible speech: minimum_voiced_frames={min_voiced_frames}, stats={stats}",
stats,
)
runtime_metadata = [
metadata["vllm_omni"]
for event in _events(client, "response.output_audio.delta")
for event in _audio_events(client)
if isinstance((metadata := event.get("metadata")), dict) and isinstance(metadata.get("vllm_omni"), dict)
]
if not any(
@@ -298,18 +378,19 @@ def _session_result(
and item.get("runner_kv_backed") is True
for item in runtime_metadata
):
raise AssertionError(f"audio events did not prove the scheduler data plane: {runtime_metadata}")
raise _AudioAcceptanceError(
"scheduler_data_plane", "audio events did not prove the scheduler data plane", stats
)
response_ids = _response_ids(client)
if len(response_ids) != 1:
raise AssertionError(f"continuous stream used multiple responses: {sorted(response_ids)}")
raise _AudioAcceptanceError(
"response_identity", f"continuous stream requires one response: count={len(response_ids)}", stats
)
return (
raw,
response_ids,
{
**stats,
"audio_chunks": chunks,
"audio_samples": len(raw) // 2,
"audio_rms": rms,
"response_ids": sorted(response_ids),
},
)
@@ -332,6 +413,8 @@ def _save(output_dir: Path, name: str, client: RawRealtimeProbe, raw: bytes) ->
async def run(args: argparse.Namespace) -> dict[str, object]:
if getattr(args, "sessions", None) is not None:
return await run_load(args)
input_path = Path(args.input_wav)
input_identity = _input_identity(
input_path,
@@ -381,14 +464,12 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
)
await asyncio.gather(
wait_for(
lambda: len(primary.events.audio_bytes()) // (2 * FRAME_SAMPLES) >= primary_frames - args.max_frame_deficit,
lambda: len(_audio_bytes(primary)) // (2 * FRAME_SAMPLES) >= primary_frames - args.max_frame_deficit,
timeout_s=args.timeout_s,
label="primary frame coverage",
),
wait_for(
lambda: (
len(secondary.events.audio_bytes()) // (2 * FRAME_SAMPLES) >= secondary_frames - args.max_frame_deficit
),
lambda: (len(_audio_bytes(secondary)) // (2 * FRAME_SAMPLES) >= secondary_frames - args.max_frame_deficit),
timeout_s=args.timeout_s,
label="secondary frame coverage",
),
@@ -426,16 +507,14 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
await asyncio.gather(
wait_for(
lambda: (
len(secondary.events.audio_bytes()) // (2 * FRAME_SAMPLES)
>= secondary_total_frames - args.max_frame_deficit
len(_audio_bytes(secondary)) // (2 * FRAME_SAMPLES) >= secondary_total_frames - args.max_frame_deficit
),
timeout_s=args.timeout_s,
label="survivor frame coverage after slot recycle",
),
wait_for(
lambda: (
len(replacement.events.audio_bytes()) // (2 * FRAME_SAMPLES)
>= replacement_frames - args.max_frame_deficit
len(_audio_bytes(replacement)) // (2 * FRAME_SAMPLES) >= replacement_frames - args.max_frame_deficit
),
timeout_s=args.timeout_s,
label="replacement frame coverage",
@@ -493,6 +572,284 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
return result
def _distribution(values: list[float]) -> dict[str, int | float | None]:
if not values:
return {"count": 0, "median": None, "p99": None, "max": None, "argmax_index": None}
return {
"count": len(values),
"median": float(np.median(values)),
"p99": float(np.quantile(values, 0.99)),
"max": max(values),
"argmax_index": int(np.argmax(values)),
}
def _load_metrics(client: RawRealtimeProbe, sends: list[tuple[float, float, float]]) -> dict[str, object]:
# Use the collector's local clock, never a timestamp supplied by the server.
packets: list[dict[str, object]] = []
response_ids: set[str] = set()
packet_times: list[float] = []
packet_bytes: list[int] = []
invalid_packets: list[dict[str, object]] = []
samples = 0
for index, (raw, received) in enumerate(zip(client.events.events, client.events.event_received_at_s, strict=True)):
if raw.get("type") not in AUDIO_DELTA_EVENT_TYPES:
continue
try:
chunk = _validated_audio_event(raw)
except ValueError as exc:
# Only fixed diagnostic codes from the validator are exported,
# never audio payloads or details from the remote endpoint.
invalid_packets.append({"event_index": index, "received_at_s": received, "code": str(exc)})
continue
if chunk:
samples += len(chunk) // 2
packet_times.append(received)
packet_bytes.append(len(chunk))
response_id = raw.get("response_id")
packets.append({"received_at_s": received, "samples": len(chunk) // 2, "response_id": response_id})
if isinstance(response_id, str) and response_id:
response_ids.add(response_id)
intervals = [(b - a) * 1000 for a, b in zip(packet_times, packet_times[1:])]
first_send = sends[0][1] if sends else None
audio_duration = samples / SAMPLE_RATE_HZ
first_audio_ms = (packet_times[0] - first_send) * 1000 if packet_times and first_send is not None else None
client_rtf = (packet_times[-1] - first_send) / audio_duration if audio_duration and first_send is not None else None
continuity = compute_continuity_stats(packet_times, packet_bytes, SAMPLE_RATE_HZ) if packet_times else None
lateness = _distribution([max(0.0, sent - planned) * 1000 for planned, sent, _ in sends])
p99_lateness = lateness["p99"]
return {
"input_frames": len(sends),
"output_samples": samples,
# Populate frame accounting from _session_result, including on failure.
"frame_deficit": None,
"response_ids": sorted(response_ids),
"client_first_audio_after_stream_start_ms": first_audio_ms,
"client_stream_rtf": client_rtf,
"client_audio_packet_interval_ms": _distribution(intervals),
"client_send_lateness_ms": lateness,
"client_pacing_warning": isinstance(p99_lateness, float) and p99_lateness > FRAME_PERIOD_S * 1000,
"audio_underrun_s": continuity.max_underrun_s if continuity else None,
"audio_underrun_event_count": continuity.underrun_event_count if continuity else None,
"audio_continuity_ok": continuity.is_continuous if continuity else None,
"input_timeline": [
{"frame_index": i, "planned_at_s": planned, "send_started_at_s": sent, "send_completed_at_s": completed}
for i, (planned, sent, completed) in enumerate(sends)
],
"audio_packet_timeline": packets,
"invalid_audio_packets": invalid_packets,
}
def _check_load_connection(client: RawRealtimeProbe) -> None:
if client.events.errors():
raise RuntimeError("server emitted a Realtime error (see the session error count)")
if client._reader_task is not None and client._reader_task.done():
client._reader_task.result() # Propagate malformed JSON/reader failures.
raise RuntimeError("connection closed before session completion")
async def _wait_load_event(client: RawRealtimeProbe, event_type: str, timeout_s: float) -> None:
def received() -> bool:
if client.events.count(event_type):
return True
_check_load_connection(client)
return False
await wait_for(received, timeout_s=timeout_s, label=event_type)
async def _paced_load_frames(
client: RawRealtimeProbe,
pcm: np.ndarray,
*,
epoch: float,
timeout_s: float,
sends: list[tuple[float, float, float]],
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
next_send = epoch
for seq in range(math.ceil(pcm.size / FRAME_SAMPLES)):
await sleep(max(0.0, next_send - clock()))
_check_load_connection(client)
event = _frame_event(pcm[seq * FRAME_SAMPLES : (seq + 1) * FRAME_SAMPLES], seq)
started = clock()
await asyncio.wait_for(client.send(event), timeout=timeout_s)
completed = clock()
sends.append((epoch + seq * FRAME_PERIOD_S, started, completed))
# Do not burst to catch up after a stalled send; report accumulated lag.
next_send = max(epoch + (seq + 1) * FRAME_PERIOD_S, completed + FRAME_PERIOD_S)
async def _request_load_close(client: RawRealtimeProbe, timeout_s: float) -> None:
await client.send({"type": "session.close"})
await _wait_load_event(client, "session.closed", timeout_s)
async def _run_load_session(
args: argparse.Namespace,
pcm: np.ndarray,
index: int,
ready: asyncio.Future[None],
start: asyncio.Future[float],
) -> dict[str, object]:
session_id = f"personaplex-load-{index}-{uuid.uuid4().hex}"
client = RawRealtimeProbe(_realtime_url(args.url, args.model, session_id), close_timeout_s=args.cleanup_timeout_s)
sends: list[tuple[float, float, float]] = []
error: str | None = None
cleanup_error: str | None = None
metrics: dict[str, object] | None = None
session_stats: dict[str, object] = {}
acceptance_check: str | None = None
phase = "admission"
try:
async with client:
try:
await asyncio.wait_for(
client.send(_session_update(args, session_id=session_id, persona=args.persona)), args.timeout_s
)
await _wait_load_event(client, "session.created", args.timeout_s)
capabilities = _capabilities(_events(client, "session.created")[-1])
if (
capabilities.get("chunk_period_ms") != 80
or capabilities.get("supports_multi_session_same_replica") is not True
):
raise AssertionError("server did not advertise PersonaPlex multi-session capabilities")
ready.set_result(None)
phase = "stream"
epoch = await asyncio.shield(start)
await _paced_load_frames(client, pcm, epoch=epoch, timeout_s=args.timeout_s, sends=sends)
phase = "drain"
# Fixed, reported observation window. Closing must not extend
# it and let a slow session pass by flushing after the cutoff.
await asyncio.sleep(args.drain_s)
_check_load_connection(client)
_, _, session_stats = _session_result(
client, input_frames=len(sends), args=args, minimum_chunks=args.minimum_audio_chunks
)
finally:
metrics = _load_metrics(client, sends)
if client.events.count("session.created") and not client.events.count("session.closed"):
try:
# Send and acknowledgement share one short cleanup budget,
# independent of the potentially long inference timeout.
await asyncio.wait_for(
_request_load_close(client, args.cleanup_timeout_s), args.cleanup_timeout_s
)
except (OSError, asyncio.TimeoutError, ValueError, RuntimeError, WebSocketException) as exc:
cleanup_error = f"cleanup: {type(exc).__name__}"
except (OSError, asyncio.TimeoutError, ValueError, RuntimeError, AssertionError, WebSocketException) as exc:
if isinstance(exc, _AudioAcceptanceError):
acceptance_check = exc.check
session_stats = exc.stats
error = f"{phase}: AssertionError: {exc}"
else:
# Other assertions/errors may embed handshake credentials or wire
# metadata. Only our local numeric acceptance diagnostics are safe.
error = f"{phase}: {type(exc).__name__}"
finally:
# Admission failure must not leave the other sessions at the barrier.
if not ready.done():
ready.set_result(None)
if metrics is None:
metrics = _load_metrics(client, sends)
if cleanup_error:
error = f"{error}; {cleanup_error}" if error else cleanup_error
if client.events.errors():
error = error or "server emitted a Realtime error"
if metrics["invalid_audio_packets"]:
error = error or "invalid audio packets"
first_audio = metrics["client_first_audio_after_stream_start_ms"]
if isinstance(first_audio, float) and first_audio < 0:
error = error or "audio arrived before the first input frame"
rtf = metrics["client_stream_rtf"]
if args.max_client_rtf is not None and isinstance(rtf, float) and rtf > args.max_client_rtf:
error = error or f"client_stream_rtf exceeds {args.max_client_rtf}"
return {
"index": index,
"session_id": session_id,
"ok": error is None,
"error": error,
"acceptance_check": acceptance_check,
"server_error_count": len(client.events.errors()),
"server_error_codes": [
str(body.get("code", "unknown")) if isinstance(body := event.get("error"), dict) else "unknown"
for event in client.events.errors()
],
**metrics,
**session_stats,
}
async def run_load(args: argparse.Namespace) -> dict[str, object]:
input_path = Path(args.input_wav)
identity = _input_identity(input_path, expected_sha256=args.expected_input_sha256)
pcm = _read_wav_as_float32(input_path)
if args.tail_s:
pcm = np.concatenate([pcm, np.zeros(round(args.tail_s * SAMPLE_RATE_HZ), dtype=np.float32)])
pcm = pcm[: args.load_frames * FRAME_SAMPLES]
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Refuse to silently overwrite evidence from a previous sweep.
result_path = output_dir / "load-result.json"
if result_path.exists():
raise FileExistsError(result_path)
loop = asyncio.get_running_loop()
start: asyncio.Future[float] = loop.create_future()
ready: list[asyncio.Future[None]] = [loop.create_future() for _ in range(args.sessions)]
tasks = [asyncio.create_task(_run_load_session(args, pcm, i, r, start)) for i, r in enumerate(ready)]
try:
await asyncio.gather(*ready)
start.set_result(time.monotonic())
rows = await asyncio.gather(*tasks)
finally:
for task in tasks:
if not task.done():
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
seen: set[str] = set()
shared: set[str] = set()
for row in rows:
assert isinstance(row["response_ids"], list)
response_ids = set(row["response_ids"])
shared.update(seen & response_ids)
seen.update(response_ids)
for row in rows:
assert isinstance(row["response_ids"], list)
if shared.intersection(row["response_ids"]):
row.update(ok=False, error="concurrent sessions shared a response id")
result = {
"schema_version": 1,
"mode": "paced_multi_session",
"model": args.model,
"requested_sessions": args.sessions,
"passed_sessions": sum(row["ok"] is True for row in rows),
"ok": all(row["ok"] is True for row in rows),
"input": {**identity, "tail_s": args.tail_s, "load_frames": args.load_frames},
"frame_period_s": FRAME_PERIOD_S,
"drain_s": args.drain_s,
"cleanup_timeout_s": args.cleanup_timeout_s,
"client_pacing_warning_threshold_ms": FRAME_PERIOD_S * 1000,
"client_pacing_warning_sessions": [row["index"] for row in rows if row["client_pacing_warning"]],
"max_client_rtf": args.max_client_rtf,
"acceptance": {
"max_frame_deficit": args.max_frame_deficit,
"min_voiced_frames": args.min_voiced_frames,
"minimum_audio_chunks": args.minimum_audio_chunks,
"voiced_frame_rms_threshold": args.voiced_frame_rms_threshold,
},
"shared_response_ids": sorted(shared),
"sessions": rows,
"reported_server": {"revision": args.server_revision, "hardware": args.server_hardware},
"measurement_origin": "client monotonic clock; admission excluded; streaming and drain retained",
"interval_definition": "nonempty audio packet arrivals, not server ticks or per-frame inference latency",
}
with result_path.open("x", encoding="utf-8") as stream:
json.dump(result, stream, ensure_ascii=False, indent=2, allow_nan=False)
return result
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime?duplex=1")
@@ -510,6 +867,12 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument("--tail-s", type=float, default=2.0)
parser.add_argument("--drain-s", type=float, default=2.0)
parser.add_argument("--timeout-s", type=float, default=180.0)
parser.add_argument(
"--cleanup-timeout-s",
type=float,
default=5.0,
help="Load mode: shared session-close/ack budget; also bounds the websocket close handshake.",
)
parser.add_argument(
"--max-frame-deficit",
type=int,
@@ -536,11 +899,39 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
default=0,
help="Frames sent after slot reuse; 0 replays the full input so the replacement must produce audible output.",
)
return parser.parse_args(argv)
parser.add_argument(
"--sessions", type=int, help="Opt into paced load mode with N sessions; omit for the lifecycle test."
)
parser.add_argument(
"--load-frames", type=int, default=1000, help="Maximum input frames per load session (no repetition)."
)
parser.add_argument("--max-client-rtf", type=float, help="Optional client stream-window RTF acceptance ceiling.")
parser.add_argument("--server-revision", help="User-reported server commit/version; not verified by this client.")
parser.add_argument(
"--server-hardware", help="User-reported server hardware; not inferred from the client machine."
)
args = parser.parse_args(argv)
if args.sessions is not None:
if not 1 <= args.sessions <= 64 or not 1 <= args.load_frames <= 10000:
parser.error("load mode requires sessions in [1,64] and load-frames in [1,10000]")
for name in ("timeout_s", "cleanup_timeout_s", "drain_s", "tail_s"):
value = getattr(args, name)
if not math.isfinite(value) or value < 0 or (name in ("timeout_s", "cleanup_timeout_s") and value == 0):
parser.error(f"{name} must be finite and non-negative (timeout must be positive)")
if args.max_client_rtf is not None and (not math.isfinite(args.max_client_rtf) or args.max_client_rtf <= 0):
parser.error("max-client-rtf must be finite and positive")
if args.max_frame_deficit < 0 or args.min_voiced_frames < 1 or args.minimum_audio_chunks < 1:
parser.error("frame-deficit must be non-negative; voiced frames and audio chunks must be positive")
if not math.isfinite(args.voiced_frame_rms_threshold) or args.voiced_frame_rms_threshold < 0:
parser.error("voiced-frame-rms-threshold must be finite and non-negative")
return args
def main() -> None:
print(json.dumps(asyncio.run(run(parse_args())), ensure_ascii=False, indent=2))
result = asyncio.run(run(parse_args()))
print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False))
if result.get("mode") == "paced_multi_session" and not result["ok"]:
raise SystemExit(1)
if __name__ == "__main__":
@@ -0,0 +1,568 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import base64
import json
import wave
from contextlib import asynccontextmanager
from typing import TypedDict
import numpy as np
import pytest
import websockets
from tests.e2e.online_serving import personaplex_realtime_duplex as driver
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _ServerState(TypedDict):
index: int
closed: bool
frames: int
close_requested: bool
def _args(tmp_path, *extra):
wav_path = tmp_path / "input.wav"
with wave.open(str(wav_path), "wb") as stream:
stream.setnchannels(1)
stream.setsampwidth(2)
stream.setframerate(driver.SAMPLE_RATE_HZ)
stream.writeframes(np.full(driver.FRAME_SAMPLES * 2, 2000, dtype="<i2").tobytes())
return driver.parse_args(
[
"--model",
"fixture-only",
"--input-wav",
str(wav_path),
"--output-dir",
str(tmp_path / "out"),
"--sessions",
"2",
"--load-frames",
"2",
"--tail-s",
"0",
"--drain-s",
"0.04",
"--timeout-s",
"3",
"--cleanup-timeout-s",
"1",
"--min-voiced-frames",
"1",
"--minimum-audio-chunks",
"1",
"--max-frame-deficit",
"0",
*extra,
]
)
def _audio(response_id="r", samples=1920, event_type="response.output_audio.delta"):
return {
"type": event_type,
"response_id": response_id,
"delta": base64.b64encode(np.full(samples, 2000, dtype="<i2").tobytes()).decode("ascii"),
"sample_rate_hz": driver.SAMPLE_RATE_HZ,
"metadata": {
"vllm_omni": {
"runtime_impl": "scheduler_data_plane",
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
}
},
}
@asynccontextmanager
async def _server(mode="success"):
states: list[_ServerState] = []
async def handle(ws):
index = len(states)
state: _ServerState = {"index": index, "closed": False, "frames": 0, "close_requested": False}
states.append(state)
response_id = "shared" if mode == "shared_response" else f"r-{index}"
try:
async for raw in ws:
message = json.loads(raw)
if message["type"] == "session.update":
if mode == "reject_second" and index == 1:
await ws.send(json.dumps({"type": "error", "error": {"code": "resource_exhausted"}}))
continue
if mode == "missing_capabilities":
await ws.send(
json.dumps(
{
"type": "session.created",
"session": {"resume_token": "must-not-be-exported"},
}
)
)
continue
await ws.send(
json.dumps(
{
"type": "session.created",
"session": {
"resume_token": "must-not-be-exported",
"capabilities": {
"chunk_period_ms": 80,
"supports_multi_session_same_replica": True,
},
},
}
)
)
await ws.send(json.dumps({"type": "response.created", "response": {"id": response_id}}))
elif message["type"] == "input_audio_buffer.append":
state["frames"] += 1
if mode == "short_audio" and state["frames"] == 2:
continue
if mode == "disconnect":
await ws.close()
return
if mode == "bad_json":
await ws.send("{")
elif mode not in ("silent", "late_on_close"):
event_type = (
"response.audio.delta" if mode == "legacy_audio_event" else "response.output_audio.delta"
)
packet = _audio(response_id, event_type=event_type)
if mode == "private_metadata":
packet["metadata"] = {"vllm_omni": {"private": "remote-detail-must-not-be-exported"}}
if mode == "odd_packet":
raw_audio = base64.b64decode(packet["delta"])
raw_audio = raw_audio + b"\x00" if state["frames"] == 1 else raw_audio[:-1]
packet["delta"] = base64.b64encode(raw_audio).decode("ascii")
if state["frames"] == 2:
if mode == "missing_packet_rate":
packet.pop("sample_rate_hz")
elif mode == "string_packet_rate":
packet["sample_rate_hz"] = "24000"
elif mode == "invalid_base64":
packet["delta"] = "!" + packet["delta"]
elif mode == "orphan_audio":
orphan = _audio(response_id)
orphan.pop("response_id")
await ws.send(json.dumps(orphan))
await ws.send(json.dumps(packet))
elif message["type"] == "session.close":
state["close_requested"] = True
if mode == "late_on_close":
await ws.send(json.dumps(_audio(response_id, samples=driver.FRAME_SAMPLES * state["frames"])))
if mode == "error_on_close":
await ws.send(json.dumps({"type": "error", "error": {"code": "cleanup_failed"}}))
if mode != "no_close_ack":
await ws.send(json.dumps({"type": "session.closed"}))
finally:
state["closed"] = True
async with websockets.serve(handle, "127.0.0.1", 0) as server:
port = server.sockets[0].getsockname()[1]
yield f"ws://127.0.0.1:{port}/v1/realtime", states
def test_omitted_sessions_preserves_lifecycle_mode():
args = driver.parse_args(["--model", "unused", "--input-wav", "unused.wav"])
assert args.sessions is None
@pytest.mark.parametrize(
"options",
[
["--sessions", "0"],
["--sessions", "65"],
["--load-frames", "0"],
["--load-frames", "10001"],
["--drain-s", "nan"],
["--tail-s", "-1"],
["--timeout-s", "0"],
["--timeout-s", "inf"],
["--cleanup-timeout-s", "0"],
["--cleanup-timeout-s", "nan"],
["--max-client-rtf", "-1"],
["--max-frame-deficit", "-1"],
["--minimum-audio-chunks", "0"],
["--min-voiced-frames", "0"],
["--voiced-frame-rms-threshold", "nan"],
],
)
def test_load_rejects_invalid_configuration(tmp_path, options):
with pytest.raises(SystemExit):
_args(tmp_path, *options)
def test_metric_clock_and_packet_frame_distinction():
client = driver.RawRealtimeProbe("ws://unused")
client.events.add({"type": "session.created", "session": {"resume_token": "secret"}}, received_at_s=7)
for when in (10.4, 10.8, 11.6):
event = _audio(samples=driver.FRAME_SAMPLES * 5)
event["_client_received_at_s"] = -999.0
client.events.add(event, received_at_s=when)
report = driver._load_metrics(client, [(10, 10, 10.01)])
intervals = report["client_audio_packet_interval_ms"]
assert isinstance(intervals, dict)
assert intervals["count"] == 2
assert intervals["median"] == pytest.approx(600)
assert intervals["p99"] == pytest.approx(796)
assert intervals["max"] == pytest.approx(800)
assert intervals["argmax_index"] == 1
assert report["client_first_audio_after_stream_start_ms"] == pytest.approx(400)
assert report["output_samples"] == 15 * driver.FRAME_SAMPLES
assert report["audio_underrun_s"] == pytest.approx(0.4)
assert report["audio_continuity_ok"] is False
underruns = report["audio_underrun_event_count"]
assert isinstance(underruns, int) and underruns >= 1
assert report["client_pacing_warning"] is False
assert "secret" not in json.dumps(report)
assert "-999" not in json.dumps(report)
def test_empty_output_has_missing_not_zero_latency():
report = driver._load_metrics(driver.RawRealtimeProbe("ws://unused"), [])
assert report["client_stream_rtf"] is None
assert report["audio_continuity_ok"] is None
assert report["audio_underrun_s"] is None
intervals = report["client_audio_packet_interval_ms"]
assert isinstance(intervals, dict)
assert intervals["p99"] is None
def test_failed_acceptance_retains_frame_counts_without_remote_data():
client = driver.RawRealtimeProbe("ws://unused")
client.events.add(_audio(), received_at_s=1.0)
args = driver.parse_args(
["--model", "unused", "--input-wav", "unused.wav", "--max-frame-deficit", "0", "--min-voiced-frames", "1"]
)
with pytest.raises(AssertionError) as raised:
driver._session_result(client, input_frames=2, args=args, minimum_chunks=1)
assert getattr(raised.value, "check", None) == "frame_deficit"
stats = getattr(raised.value, "stats", {})
assert stats["frame_deficit"] == 1 and type(stats["frame_deficit"]) is int
assert stats["output_frames"] == stats["voiced_frames"] == 1
assert stats["frame_coverage_ratio"] == 0.5
@pytest.mark.parametrize("event_type", sorted(driver.AUDIO_DELTA_EVENT_TYPES))
def test_session_result_accepts_current_and_legacy_audio_event_names(tmp_path, event_type):
client = driver.RawRealtimeProbe("ws://unused")
client.events.add(_audio(samples=driver.FRAME_SAMPLES, event_type=event_type), received_at_s=1.0)
raw, response_ids, stats = driver._session_result(
client,
input_frames=1,
args=_args(tmp_path, "--sessions", "1"),
minimum_chunks=1,
)
assert len(raw) == driver.FRAME_SAMPLES * 2
assert response_ids == {"r"}
assert stats["frame_deficit"] == 0
def test_legacy_audio_event_name_contributes_load_metrics():
client = driver.RawRealtimeProbe("ws://unused")
client.events.add(_audio(samples=driver.FRAME_SAMPLES * 5, event_type="response.audio.delta"), received_at_s=10.4)
report = driver._load_metrics(client, [(10.0, 10.0, 10.01)])
assert report["output_samples"] == driver.FRAME_SAMPLES * 5
assert report["response_ids"] == ["r"]
assert report["invalid_audio_packets"] == []
@pytest.mark.asyncio
@pytest.mark.parametrize("sessions", [1, 2, 4])
async def test_real_websocket_load_success(tmp_path, sessions):
args = _args(tmp_path, "--sessions", str(sessions))
async with _server() as (url, states):
args.url = url
result = await driver.run(args)
assert result["ok"] is True
assert result["passed_sessions"] == sessions
rows = result["sessions"]
assert isinstance(rows, list)
assert len(rows) == sessions
assert all(row["frame_deficit"] == 0 for row in rows)
for row in rows:
assert type(row["frame_deficit"]) is int
assert row["output_frames"] == row["voiced_frames"] == row["audio_chunks"] == 2
assert row["silent_frames"] == 0
assert row["frame_coverage_ratio"] == 1.0
assert row["audio_rms"] == pytest.approx(2000 / 32768)
assert row["acceptance_check"] is None
assert isinstance(row["audio_continuity_ok"], bool)
assert all(s["closed"] and s["close_requested"] for s in states)
assert "must-not-be-exported" not in json.dumps(result)
saved = json.loads((tmp_path / "out/load-result.json").read_text())
assert saved == result
origins = [row["input_timeline"][0]["planned_at_s"] for row in rows]
assert len(set(origins)) == 1
@pytest.mark.asyncio
async def test_real_websocket_load_accepts_legacy_audio_event_name(tmp_path):
args = _args(tmp_path, "--sessions", "2")
async with _server("legacy_audio_event") as (url, states):
args.url = url
result = await driver.run(args)
assert result["ok"] is True
assert result["passed_sessions"] == 2
assert result["shared_response_ids"] == []
assert all(s["closed"] and s["close_requested"] for s in states)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mode",
[
"reject_second",
"silent",
"disconnect",
"no_close_ack",
"shared_response",
"late_on_close",
"error_on_close",
"bad_json",
"missing_capabilities",
],
)
async def test_real_websocket_failures_remain_in_results(tmp_path, mode):
args = _args(tmp_path)
async with _server(mode) as (url, states):
args.url = url
result = await asyncio.wait_for(driver.run(args), timeout=4)
assert result["ok"] is False
assert "must-not-be-exported" not in json.dumps(result)
assert result["requested_sessions"] == 2
rows = result["sessions"]
assert isinstance(rows, list)
assert len(rows) == 2
assert all(s["closed"] for s in states)
if mode == "reject_second":
assert result["passed_sessions"] == 1
rejected = next(row for row in rows if not row["ok"])
assert rejected["input_frames"] == 0
assert rejected["client_stream_rtf"] is None
assert rejected["server_error_codes"] == ["resource_exhausted"]
if mode == "silent":
# A failed measurement must still release its admitted session.
assert all(s["close_requested"] for s in states)
if mode == "late_on_close":
assert all(row["output_samples"] == 0 for row in rows)
if mode == "shared_response":
assert result["passed_sessions"] == 0
@pytest.mark.asyncio
async def test_existing_report_is_not_overwritten(tmp_path):
args = _args(tmp_path)
path = tmp_path / "out/load-result.json"
path.parent.mkdir()
path.write_text("previous measurement")
with pytest.raises(FileExistsError):
await driver.run(args)
assert path.read_text() == "previous measurement"
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["success", "no_close_ack"])
async def test_cancellation_closes_all_connections(tmp_path, mode):
args = _args(tmp_path, "--drain-s", "10", "--timeout-s", "30")
async with _server(mode) as (url, states):
args.url = url
task = asyncio.create_task(driver.run(args))
async def started():
while len(states) < 2 or not all(state["frames"] for state in states):
await asyncio.sleep(0.01)
await asyncio.wait_for(started(), timeout=3)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=3)
assert all(s["closed"] for s in states)
@pytest.mark.asyncio
async def test_client_rtf_ceiling_marks_slow_sessions_failed(tmp_path):
args = _args(tmp_path, "--max-client-rtf", "0.001")
async with _server() as (url, _):
args.url = url
result = await driver.run(args)
assert result["passed_sessions"] == 0
rows = result["sessions"]
assert isinstance(rows, list)
assert all("client_stream_rtf exceeds" in row["error"] for row in rows)
@pytest.mark.asyncio
async def test_stalled_send_does_not_cause_catchup_burst():
class Clock:
now = 10.0
def monotonic(self):
return self.now
async def sleep(self, delay):
self.now += delay
class Client(driver.RawRealtimeProbe):
async def send(self, event):
if event["audio_end_ms"] == 80:
clock.now += 0.25
clock = Clock()
sends: list[tuple[float, float, float]] = []
await driver._paced_load_frames(
Client("ws://unused"),
np.zeros(driver.FRAME_SAMPLES * 3, dtype="<f4"),
epoch=10.0,
timeout_s=1.0,
sends=sends,
clock=clock.monotonic,
sleep=clock.sleep,
)
assert len(sends) == 3
assert sends[1][1] >= sends[0][2] + driver.FRAME_PERIOD_S
assert sends[1][1] - sends[1][0] == pytest.approx(0.25)
assert sends[2][1] >= sends[1][2] + driver.FRAME_PERIOD_S
metrics = driver._load_metrics(Client("ws://unused"), sends)
assert metrics["client_pacing_warning"] is True
assert asyncio.sleep != clock.sleep
@pytest.mark.asyncio
async def test_failed_frame_acceptance_preserves_numeric_diagnostics(tmp_path):
args = _args(tmp_path, "--sessions", "1")
async with _server("short_audio") as (url, states):
args.url = url
result = await driver.run(args)
rows = result["sessions"]
assert isinstance(rows, list)
row = rows[0]
assert row["ok"] is False
assert row["acceptance_check"] == "frame_deficit"
assert row["frame_deficit"] == 1 and type(row["frame_deficit"]) is int
assert row["output_frames"] == row["voiced_frames"] == row["audio_chunks"] == 1
assert row["frame_coverage_ratio"] == 0.5
assert row["audio_rms"] == pytest.approx(2000 / 32768)
assert "input=2" in row["error"] and "maximum_deficit=0" in row["error"]
assert all(state["closed"] and state["close_requested"] for state in states)
assert json.loads((tmp_path / "out/load-result.json").read_text()) == result
@pytest.mark.asyncio
async def test_acceptance_failure_does_not_export_remote_metadata(tmp_path):
args = _args(tmp_path, "--sessions", "1")
async with _server("private_metadata") as (url, _):
args.url = url
result = await driver.run(args)
rows = result["sessions"]
assert isinstance(rows, list)
row = rows[0]
assert row["ok"] is False
assert row["acceptance_check"] == "scheduler_data_plane"
assert "remote-detail" not in json.dumps(result)
assert "must-not-be-exported" not in json.dumps(result)
@pytest.mark.parametrize("ok", [True, False])
def test_load_main_exit_status(tmp_path, monkeypatch, ok):
args = _args(tmp_path)
async def completed(_args):
return {"mode": "paced_multi_session", "ok": ok}
monkeypatch.setattr(driver, "parse_args", lambda: args)
monkeypatch.setattr(driver, "run", completed)
if ok:
driver.main()
else:
with pytest.raises(SystemExit) as exc:
driver.main()
assert exc.value.code == 1
@pytest.mark.asyncio
async def test_handshake_rejection_is_retained_without_server_details(tmp_path, monkeypatch):
from websockets.exceptions import InvalidHandshake
async def rejected(_self):
raise InvalidHandshake("credentials-in-remote-handshake-detail")
monkeypatch.setattr(driver.RawRealtimeProbe, "__aenter__", rejected)
result = await asyncio.wait_for(driver.run(_args(tmp_path)), timeout=3)
assert result["ok"] is False
rows = result["sessions"]
assert isinstance(rows, list) and len(rows) == 2
assert all(row["error"] == "admission: InvalidHandshake" for row in rows)
assert "credentials-in-remote-handshake-detail" not in json.dumps(result)
@pytest.mark.asyncio
@pytest.mark.parametrize("sessions", [1, 2])
@pytest.mark.parametrize(
"mode",
[
"missing_packet_rate",
"string_packet_rate",
"invalid_base64",
"orphan_audio",
"odd_packet",
],
)
async def test_load_rejects_invalid_individual_audio_packets(tmp_path, sessions, mode):
# The concatenated PCM can still meet coverage/voicing thresholds, so the
# validator must also check each packet before accepting the whole run.
args = _args(tmp_path, "--sessions", str(sessions))
async with _server(mode) as (url, states):
args.url = url
result = await asyncio.wait_for(driver.run(args), timeout=4)
assert result["ok"] is False
assert result["passed_sessions"] == 0
rows = result["sessions"]
assert isinstance(rows, list) and len(rows) == sessions
assert all(row["error"] is not None for row in rows)
expected_code = {
"missing_packet_rate": "invalid_audio_sample_rate",
"string_packet_rate": "invalid_audio_sample_rate",
"invalid_base64": "invalid_audio_base64",
"orphan_audio": "missing_audio_response_id",
"odd_packet": "unaligned_pcm16_packet",
}[mode]
assert all({packet["code"] for packet in row["invalid_audio_packets"]} == {expected_code} for row in rows)
assert all(
sum(packet["samples"] for packet in row["audio_packet_timeline"]) == row["output_samples"] for row in rows
)
assert all(state["closed"] and state["close_requested"] for state in states)
assert json.loads((tmp_path / "out/load-result.json").read_text()) == result
def test_load_allows_empty_and_multi_frame_audio_packets():
client = driver.RawRealtimeProbe("ws://unused")
client.events.add(_audio(samples=0), received_at_s=1.0)
client.events.add(_audio(samples=driver.FRAME_SAMPLES * 5), received_at_s=1.4)
metrics = driver._load_metrics(client, [(1.0, 1.0, 1.001)])
assert metrics["invalid_audio_packets"] == []
assert metrics["output_samples"] == driver.FRAME_SAMPLES * 5
packets = metrics["audio_packet_timeline"]
assert isinstance(packets, list) and len(packets) == 1
def test_load_packet_diagnostics_do_not_copy_remote_payload():
client = driver.RawRealtimeProbe("ws://unused")
event = _audio()
event["delta"] = "private-remote-payload!"
client.events.add(event, received_at_s=1.4)
metrics = driver._load_metrics(client, [])
assert metrics["output_samples"] == 0
assert metrics["invalid_audio_packets"] == [
{
"event_index": 0,
"received_at_s": 1.4,
"code": "invalid_audio_base64",
}
]
assert "private-remote-payload" not in json.dumps(metrics)