Service node ends its call when the loop cancels every task; test order and timing leaks

_serve_once treated a cancelled call future as finished, so an event loop
cancelling all tasks at once (shutdown) never sent the worker a cancel and the
call's thread kept asyncio.run waiting for SILENCE_S. The cameras audio test
now removes its voice flow and device, the unpaired-frame test waits for its
first frame before the unpair, and test_connector no longer inherits the
controller an app test bound to demand.

Claude-Session: https://claude.ai/code/session_01SSi5AvZ7pFmySK8zy3NPMh
This commit is contained in:
2026-10-27 21:23:40 +01:00
parent 04d4c4792a
commit ab87cd1c07
4 changed files with 84 additions and 18 deletions
+32 -16
View File
@@ -102,6 +102,31 @@ def camera(client: TestClient, superuser_token_headers: dict[str, str]):
client.delete(f"{PREFIX}/cam1", headers=superuser_token_headers)
@pytest.fixture
def microphone(client: TestClient, superuser_token_headers: dict[str, str]):
"""A paired device with a microphone, wired in flow ``voice``. Yields its header.
Both go again afterwards: a published flow outlives the module in the
flow store, and the next module's app would load it without its device.
"""
_panels(client, superuser_token_headers, {"panels": [{"id": "mic1"}]})
device = _pair(client, superuser_token_headers, "mic1")
port = {"port": "mic", "direction": "in", "dtype": "audio"}
reported = client.put(
f"{PREFIX}/mic1/report", headers=device, json={**REPORT, "ports": [port]}
)
assert reported.status_code == 200, reported.text
node = _device_node(
"mic1",
node="panel",
provides=[{"name": "mic", "port": "mic", "dtype": "audio"}],
)
_publish_flow(client, superuser_token_headers, "voice", [node])
yield device
client.delete(f"{FLOWS}/voice", headers=superuser_token_headers)
client.delete(f"{PREFIX}/mic1", headers=superuser_token_headers)
def _ticket(client: TestClient, headers: dict[str, str]) -> str:
answer = client.post(f"{settings.API_V1_STR}/flows/ws-ticket", headers=headers)
return f"{SOCKET}?ticket={answer.json()['ticket']}"
@@ -269,24 +294,11 @@ def test_an_unpaired_device_is_closed_and_its_frames_stop(
def test_a_device_streams_audio_chunks_on_its_socket(
client: TestClient, superuser_token_headers: dict[str, str]
client: TestClient, microphone
) -> None:
"""A microphone's chunks take the camera's path, in their own family."""
_panels(client, superuser_token_headers, {"panels": [{"id": "mic1"}]})
device = _pair(client, superuser_token_headers, "mic1")
port = {"port": "mic", "direction": "in", "dtype": "audio"}
reported = client.put(
f"{PREFIX}/mic1/report", headers=device, json={**REPORT, "ports": [port]}
)
assert reported.status_code == 200, reported.text
node = _device_node(
"mic1",
node="panel",
provides=[{"name": "mic", "port": "mic", "dtype": "audio"}],
)
_publish_flow(client, superuser_token_headers, "voice", [node])
controller = client.app.state.flow_controller
with client.websocket_connect(SOCKET, headers=device) as ws:
with client.websocket_connect(SOCKET, headers=microphone) as ws:
ws.receive_json()
ws.send_bytes(frame("voice.mic", b"\x00\x01")) # an image on an audio port
ws.send_bytes(frame("voice.mic", b"\x00\x02", "audio/L16;rate=16000"))
@@ -329,9 +341,13 @@ def test_an_unpaired_device_is_closed_at_its_next_frame(
"""Without the event an unpair sends, which a full queue may have lost."""
from starlette.websockets import WebSocketDisconnect
controller = client.app.state.flow_controller
with client.websocket_connect(SOCKET, headers=camera) as device:
device.receive_json()
device.send_bytes(frame("cams.camera", b"\xff\xd8 one"))
# The credential is asked as each frame is read, so a bump before the
# server has read the first one would refuse that one too.
wait_for(lambda: (controller.values().get("cams.camera") or {}).get("value"))
client.app.state.device_store.bump("cam1")
device.send_bytes(frame("cams.camera", b"\xff\xd8 two"))
with pytest.raises(WebSocketDisconnect) as closed:
@@ -339,7 +355,7 @@ def test_an_unpaired_device_is_closed_at_its_next_frame(
device.receive_json()
assert closed.value.code == 1008
store = client.app.state.artifact_store
current = client.app.state.flow_controller.values()["cams.camera"]["value"]
current = controller.values()["cams.camera"]["value"]
assert store.volatile.get(current["digest"]) == b"\xff\xd8 one"