Files
Spoolman/tests/test_ws_broadcast.py
T
Donkie f261dee61b Fix one dead websocket subscriber starving a whole pool
SubscriptionTree.send let a per-socket failure escape the broadcast loop, so
every subscriber after the failing one silently received nothing. A socket can
start closing between the CONNECTED state check and the send, and starlette
then raises "connection is closing"; on the shared tree that means one browser
tab closing could stop Moonraker hearing about a spool.

Each subscriber is now isolated: a write failure drops that socket and the
broadcast continues. Iterate a snapshot too, since dropping mutates the set.

Dropping also had to stop going through remove(path, ...). By the time send()
is walking a node, path is the part of the route still to be traversed, not the
pool the socket subscribed with, so remove() descended into a child and looked
in the wrong set: the dead socket stayed put to fail again on the next event.

Observed in the integration suite as a subscriber missing an event it was
subscribed to, alongside "Failed to send websocket message" on an unrelated
filament broadcast.
2026-08-13 18:56:16 +02:00

104 lines
3.7 KiB
Python

"""Tests for SubscriptionTree's broadcast isolation.
Regression cover for a bug that predates the scan relay but is easiest to hit through it: one
subscriber that could not be written to used to abort the whole broadcast, so every other
subscriber in the same pool silently received nothing. On the shared tree that meant a browser
tab closing could stop Moonraker hearing about a spool.
"""
from datetime import datetime, timezone
import pytest
from starlette.websockets import WebSocketState
from spoolman.api.v1.models import Event
from spoolman.ws import SubscriptionTree
def _event() -> Event:
"""Build the smallest thing SubscriptionTree.send will serialize."""
return Event(
type="updated",
resource="test",
date=datetime(2026, 8, 13, 12, 0, 0, tzinfo=timezone.utc),
payload=Event.model_construct(),
)
class FakeWebSocket:
"""A websocket that records what it was sent, and can fail or claim to be disconnected."""
def __init__(self, *, fail: bool = False, disconnected: bool = False) -> None:
"""Initialize a connected socket, optionally one that fails or is already gone."""
self.sent: list[str] = []
self.fail = fail
state = WebSocketState.DISCONNECTED if disconnected else WebSocketState.CONNECTED
self.client_state = state
self.application_state = state
self.client = None
async def send_text(self, text: str) -> None:
"""Record the frame, or blow up the way a closing socket does."""
if self.fail:
# What starlette raises for a socket that started closing after the state check.
raise RuntimeError("connection is closing")
self.sent.append(text)
@pytest.mark.asyncio
async def test_a_failing_subscriber_does_not_starve_the_others():
"""The bug: the send raised, the loop aborted, and everyone after it got nothing."""
tree = SubscriptionTree()
healthy_before, broken, healthy_after = FakeWebSocket(), FakeWebSocket(fail=True), FakeWebSocket()
for ws in (healthy_before, broken, healthy_after):
tree.add(("spool",), ws)
await tree.send(("spool",), _event())
assert len(healthy_before.sent) == 1
assert len(healthy_after.sent) == 1
@pytest.mark.asyncio
async def test_a_failing_subscriber_is_dropped():
"""Dropped rather than left to fail again on every subsequent event."""
tree = SubscriptionTree()
broken, healthy = FakeWebSocket(fail=True), FakeWebSocket()
tree.add(("spool",), broken)
tree.add(("spool",), healthy)
await tree.send(("spool",), _event())
await tree.send(("spool",), _event())
assert len(healthy.sent) == 2
assert tree.children["spool"].subscribers == {healthy}
@pytest.mark.asyncio
async def test_a_disconnected_subscriber_is_dropped_from_its_own_pool():
"""The drop has to happen on the node being walked, not wherever the remaining path points."""
tree = SubscriptionTree()
dead, healthy = FakeWebSocket(disconnected=True), FakeWebSocket()
# Both subscribe at the ROOT, so send() meets them while `path` still has ("spool",) left
# to walk. Removing via that path would look inside the "spool" child instead.
tree.add((), dead)
tree.add((), healthy)
await tree.send(("spool",), _event())
assert tree.subscribers == {healthy}
assert len(healthy.sent) == 1
@pytest.mark.asyncio
async def test_broadcast_still_propagates_down_the_tree_after_a_failure():
"""A failure at one level must not cost the levels below it their event."""
tree = SubscriptionTree()
broken_root, deep = FakeWebSocket(fail=True), FakeWebSocket()
tree.add((), broken_root)
tree.add(("spool", "1"), deep)
await tree.send(("spool", "1"), _event())
assert len(deep.sent) == 1