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
+45
View File
@@ -1,8 +1,10 @@
"""The service node: a server on a worker, restarted when it ends, its url following."""
import asyncio
import time
from collections.abc import Callable
from fluksio.flow import remote
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import ServiceNode, service
from fluksio.flow.pipeline import Pipeline
@@ -99,6 +101,49 @@ def test_a_service_comes_back_and_its_url_follows(monkeypatch):
asyncio.run(scenario())
def test_a_service_cancelled_with_its_loop_ends_its_call(monkeypatch):
"""A loop that cancels every task at once must not strand the call's thread.
The call's own future is cancelled with the rest, so it reads done while the
thread still waits for the worker, which then keeps the loop's shutdown
waiting until the silence bound.
"""
monkeypatch.setattr(remote, "SILENCE_S", 3.0)
async def healthy(client, url):
return None
monkeypatch.setattr(ServiceNode, "_probe", staticmethod(healthy))
agent = Agent()
async def scenario() -> None:
agent.worker = RemoteWorker(
name="gpu-worker",
labels=["gpu"],
send=agent.send_json,
loop=asyncio.get_running_loop(),
host="192.0.2.5",
)
hub = RemoteWorkerHub()
hub.attach(agent.worker)
node = ServiceNode(
provides=[MessageSpec(name="url", dtype=DType.STR)],
params={"command": "serve", "worker": "gpu"},
)
node.assign_flow("voice", "parrot")
pipeline = Pipeline(nodes=[node])
node.hub = hub
await node.start()
await until(lambda: bool(agent.serves()))
await until(lambda: pipeline.state.get("voice.url") == "http://192.0.2.5:8100")
# No stop: asyncio.run cancels the node's task and the call together.
started = time.monotonic()
asyncio.run(scenario())
assert time.monotonic() - started < 2
assert [frame["op"] for frame in agent.sent] == ["serve", "cancel"]
def test_a_service_that_never_gets_healthy_reads_down(monkeypatch):
monkeypatch.setattr(service, "BACKOFF", (0.01,))