Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ equivalents for Python package metadata.

### Fixed

- Give the voice-relay tests ten seconds, named `HANG_GUARD_SECONDS`, to see the relay
finish instead of one or two. The cancellation tests cancel the relay and then wait
for its shielded cleanup, which on a runner at half speed took longer than the second
they allowed and failed a pull request that did not touch the relay (#152). None of
these waits measures latency: the relay's own deadlines are the limits each test
passes in, and those are unchanged.
- Report how old the cached Retraction Watch data is. `load_retractions()` returns the
retracted DOIs with the time the cache was written, so a "no retractions found" can be
told apart from one checked against a six-month-old copy: `describe()` gives
Expand Down
4 changes: 2 additions & 2 deletions services/api/COMMUNITY_EXPORT_MANIFEST.json
Original file line number Diff line number Diff line change
Expand Up @@ -2371,10 +2371,10 @@
"sha256": "bd888115cfa33650305a3071bad7666c2be77c36807dd39c54effff0b6cc1748"
},
{
"bytes": 34337,
"bytes": 34998,
"mode": "0644",
"path": "tests/test_voice_relay.py",
"sha256": "b5dcaae099b9224f1db374d9f1faf93edc4844f8e4eaf1a4c328b1f87be30e2a"
"sha256": "77c05f776c69a432fb16325068e77c8e7b5805a471df499b0e7386ed8702fd0d"
},
{
"bytes": 34585,
Expand Down
49 changes: 27 additions & 22 deletions services/api/tests/test_voice_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
usage_upper_cost_microusd,
)

# How long a test waits for the relay before it fails instead of hanging. None of these waits
# asserts latency: the relay's own deadlines are the RelayLimits the tests pass in. A slow
# runner needs more than the second the cancellation paths used to get (#152).
HANG_GUARD_SECONDS = 10


def config(**overrides: Any) -> LiveRelayConfig:
values = {
Expand Down Expand Up @@ -335,7 +340,7 @@ def start(
)


async def until(condition: Callable[[], bool], seconds: float = 2.0) -> None:
async def until(condition: Callable[[], bool], seconds: float = HANG_GUARD_SECONDS) -> None:
async with asyncio.timeout(seconds):
while not condition():
await asyncio.sleep(0.005)
Expand All @@ -347,7 +352,7 @@ async def scenario() -> None:
task = harness.start()
await until(lambda: bool(harness.connections))
harness.browser.send({"realtimeInput": {"text": "not an audio frame"}})
result = await asyncio.wait_for(task, 2)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "connection"
assert result.diagnostic_code == "invalid_audio"
assert result.diagnostic_stage == "browser_receive"
Expand All @@ -358,7 +363,7 @@ async def scenario() -> None:
task = disconnected.start()
await until(lambda: bool(disconnected.connections))
disconnected.browser.incoming.put_nowait({"type": "websocket.disconnect"})
result = await asyncio.wait_for(task, 2)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.diagnostic_code == "client_disconnected"
assert result.diagnostic_stage == "browser_receive"

Expand Down Expand Up @@ -419,7 +424,7 @@ def fail_send(payload: dict[str, Any]) -> None:
harness = Harness([Provider(on_send=fail_send)])
if send_kind != "setup":
harness.browser.send(audio_message())
result = await asyncio.wait_for(harness.start(), 3)
result = await asyncio.wait_for(harness.start(), HANG_GUARD_SECONDS)

assert result.reason == "unavailable"
assert result.diagnostic_code == "provider_connection_closed"
Expand Down Expand Up @@ -461,7 +466,7 @@ async def fail_checkpoint(_checkpoint: RelayCheckpoint) -> None:
fail_checkpoint if failure_site == "checkpoint" else harness.checkpoint
),
),
2,
HANG_GUARD_SECONDS,
)
assert result.reason == "unavailable"
assert result.diagnostic_code == "internal_failure"
Expand Down Expand Up @@ -494,7 +499,7 @@ async def reject_closed(result: RelayResult) -> None:
raise OSError("private-terminal-persistence-sentinel")

with pytest.raises(OSError, match="private-terminal-persistence-sentinel"):
await asyncio.wait_for(harness.start(on_closed=reject_closed), 2)
await asyncio.wait_for(harness.start(on_closed=reject_closed), HANG_GUARD_SECONDS)
assert len(attempted) == 1
assert attempted[0].reason == "unavailable"
assert attempted[0].diagnostic_code == "provider_connection_closed"
Expand Down Expand Up @@ -534,7 +539,7 @@ async def scenario() -> None:
task = harness.start()
await until(lambda: bool(harness.browser.sent))
harness.browser.send({"setup": {"model": "models/attacker"}})
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "connection"
assert harness.providers[0].sent == [build_relay_setup(config())]
assert "test-provider-key" not in json.dumps(harness.browser.sent)
Expand All @@ -552,7 +557,7 @@ async def scenario() -> None:
harness.browser.send(audio_message())
await until(lambda: any("realtimeInput" in item for item in harness.providers[0].sent))
harness.providers[0].emit({"usageMetadata": receipt(400_000)})
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "budget"
assert result.accounted_cost_usd == 1.2
assert result.observed_cost_usd == 1.2
Expand Down Expand Up @@ -589,7 +594,7 @@ def respond(payload: dict[str, Any]) -> None:
lambda: bool(harness.checkpoints) and harness.checkpoints[-1].reserved_cost_usd == 0.3
)
harness.browser.send(audio_message())
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "budget"
assert sum("activityStart" in item.get("realtimeInput", {}) for item in provider.sent) == 1
assert result.accounted_cost_usd == 0.3
Expand All @@ -612,7 +617,7 @@ def respond(payload: dict[str, Any]) -> None:
task = harness.start(limits=replace(RelayLimits(), meter_timeout_seconds=0.05))
await until(lambda: bool(harness.browser.sent))
harness.browser.send(audio_message())
result = await asyncio.wait_for(task, 2)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "unavailable"
assert result.accounted_cost_usd == 1.0
assert not result.meter_complete
Expand All @@ -636,7 +641,7 @@ def respond(payload: dict[str, Any]) -> None:
task = harness.start(limits=replace(RelayLimits(), meter_timeout_seconds=0.3))
await until(lambda: bool(harness.browser.sent))
harness.browser.send(audio_message())
result = await asyncio.wait_for(task, 2)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "unavailable"
assert result.observed_cost_usd == 0.003
assert result.accounted_cost_usd == 1.0
Expand Down Expand Up @@ -695,7 +700,7 @@ async def scenario() -> None:
lambda: bool(harness.checkpoints) and harness.checkpoints[-1].turns_started == 1
)
task.cancel()
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "connection"
assert result.accounted_cost_usd == 1.0
assert len(harness.results) == 1
Expand All @@ -718,13 +723,13 @@ async def delayed_close(result: RelayResult) -> None:
task = harness.start(on_closed=delayed_close)
await until(lambda: bool(harness.browser.sent))
task.cancel()
await asyncio.wait_for(write_started.wait(), 1)
await asyncio.wait_for(write_started.wait(), HANG_GUARD_SECONDS)
task.cancel()
await asyncio.sleep(0.01)
assert not task.done()
assert not any("relayEnd" in item for item in harness.browser.sent)
allow_write.set()
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "connection"
assert len(harness.results) == 1
assert harness.browser.closed
Expand All @@ -749,7 +754,7 @@ async def scenario() -> None:
}
assert "provider-resume-only" not in json.dumps(harness.browser.sent)
assert await stop_gemini_relay("relay-test-session")
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.provider_connections == 2
assert result.reason == "stopped"

Expand Down Expand Up @@ -783,7 +788,7 @@ async def scenario() -> None:
assert not any("activityStart" in item.get("realtimeInput", {}) for item in second.sent)
assert second.sent[0]["setup"]["sessionResumption"] == {"handle": "same-turn"}
assert await stop_gemini_relay("relay-test-session")
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "stopped"
assert result.turns_started == 1
assert result.accounted_cost_usd == 1.0
Expand Down Expand Up @@ -812,7 +817,7 @@ async def scenario() -> None:
assert second.sent[0]["setup"]["sessionResumption"] == {"handle": "fresh-safe"}
await until(lambda: harness.checkpoints[-1].reserved_cost_usd == 0.003)
assert await stop_gemini_relay("relay-test-session")
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.turns_started == 1
assert result.accounted_cost_usd == 0.003
assert result.provider_connections == 2
Expand All @@ -826,7 +831,7 @@ async def scenario() -> None:
task = harness.start()
await until(lambda: bool(harness.browser.sent))
harness.providers[0].emit({"goAway": {"timeLeft": "0.05s"}})
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "connection"
assert result.provider_connections == 1

Expand All @@ -840,7 +845,7 @@ async def scenario() -> None:
async def reject(_checkpoint: RelayCheckpoint) -> None:
raise RuntimeError("private database detail must never reach the user")

result = await asyncio.wait_for(harness.start(on_checkpoint=reject), 1)
result = await asyncio.wait_for(harness.start(on_checkpoint=reject), HANG_GUARD_SECONDS)
assert result.reason == "unavailable"
assert not harness.connections
assert "database" not in json.dumps(harness.browser.sent)
Expand Down Expand Up @@ -872,15 +877,15 @@ async def delayed_checkpoint(value: RelayCheckpoint) -> None:
RelayLimits(),
)
first = asyncio.create_task(relay.persist())
await asyncio.wait_for(first_started.wait(), 1)
await asyncio.wait_for(first_started.wait(), HANG_GUARD_SECONDS)
relay.budget.begin_turn(1.0)
relay.budget.observe(receipt(1000))
second = asyncio.create_task(relay.persist())
await asyncio.sleep(0.01)
assert entered == 1
relay.budget.observe(receipt(2000))
release_first.set()
await asyncio.wait_for(asyncio.gather(first, second), 1)
await asyncio.wait_for(asyncio.gather(first, second), HANG_GUARD_SECONDS)
# The waiting writer snapshots after the lock, not before it.
assert writes == [0.0, 0.006]

Expand All @@ -891,7 +896,7 @@ def test_server_enforces_deadline_without_waiting_for_browser_to_stop() -> None:
async def scenario() -> None:
harness = Harness()
task = harness.start(config(deadline_at=datetime.now(UTC) + timedelta(seconds=0.05)))
result = await asyncio.wait_for(task, 1)
result = await asyncio.wait_for(task, HANG_GUARD_SECONDS)
assert result.reason == "time"
assert harness.providers[0].closed

Expand Down
Loading