diff --git a/CHANGELOG.md b/CHANGELOG.md index ae29e86..52105d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Session-sync relay WebSocket — `/ws/sync/{session_id}` (#1030).** A + deliberately dumb JSON fan-out room: a text frame received from one client is + forwarded verbatim to every other client on the same session id; the server + interprets nothing (message schemas are owned by consumers). Rooms are created + on first join and garbage-collected when the last socket leaves — no history, + no replay, no persistence, so a host that crashes and rejoins the same id + resumes publishing to reconnecting subscribers with no server-side + coordination. Session ids are client-generated (`[A-Za-z0-9_-]{4,64}`); DoS + hygiene for a LAN-exposed port via frame-size (16 KB), per-room (16 sockets), + total-room (32), and per-socket rate (120 msg/s sustained, 240 burst) caps — + over-limit sockets are closed with a policy code and the room carries on, and + a peer that dies — or stalls: fan-out sends are bounded by a 5 s timeout — + mid-fan-out is dropped without disturbing delivery to the rest. `main.py` + also caps inbound WS frames at the transport (`ws_max_size=64 KB`, down from + uvicorn's 16 MB default) so oversized frames never materialize server-side. First consumer: splitscreen's "pop out to LAN" follower mode + (feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change + frames from a host window to view-only followers on other LAN devices. + Implementation in `lib/routers/ws_sync.py`; tests in `tests/test_ws_sync.py`. - **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song carries several drum charts, a **Drum part** selector appears beside the arrangement switcher (advanced settings) so a player can choose which drummer diff --git a/lib/routers/ws_sync.py b/lib/routers/ws_sync.py new file mode 100644 index 0000000..9f2dd61 --- /dev/null +++ b/lib/routers/ws_sync.py @@ -0,0 +1,140 @@ +"""Session-sync relay WebSocket — /ws/sync/{session_id} (feedBack#1030). + +A deliberately dumb fan-out room: a JSON text frame received from one client +is forwarded verbatim to every OTHER client connected to the same session id. +The server interprets nothing beyond the limits below — message schemas are +owned entirely by consumers. First consumer: splitscreen's LAN follower mode +(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change +frames from a host window to view-only followers on other LAN devices. + +Design points (full spec in the issue): + +- Rooms are created on first join and garbage-collected when the last socket + leaves. No history, no replay, no persistence — a late joiner simply waits + for the next frame. Consumers that need state on join re-send it themselves + (splitscreen answers every follower ``hello`` with a fresh ``config``). +- That statelessness is what makes consumer crash-recovery work: a host that + relaunches and rejoins the same session id resumes publishing to its + reconnecting subscribers with no server-side coordination, and an idle room + is indistinguishable from a nonexistent one. +- ``session_id`` is client-generated and opaque (``[A-Za-z0-9_-]{4,64}``); + consumers pick their own id policy (splitscreen uses a short typeable, + persistent room key). +- DoS hygiene for a port that may be LAN-exposed: frame-size cap, per-room and + total-room caps, and a per-socket inbound token-bucket rate cap. Over-limit + sockets are closed with a policy code; the room carries on. A peer that dies + mid-fan-out is dropped without wedging delivery to the rest. +""" + +import asyncio +import logging +import re +import time + +from fastapi import APIRouter, WebSocket + +log = logging.getLogger("feedBack.server") + +router = APIRouter() + +_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{4,64}$") + +# Limits. Sized generously above the first consumer's needs (splitscreen +# publishes time frames at ~15-20 Hz to a handful of viewers) while bounding +# what an open LAN port can be made to do. All module-level so tests (and a +# desperate operator) can override them. +MAX_FRAME_BYTES = 16 * 1024 +MAX_CLIENTS_PER_ROOM = 16 +MAX_ROOMS = 32 +RATE_MSGS_PER_SEC = 120.0 # sustained inbound frames per socket +RATE_BURST = 240.0 # token-bucket burst headroom +# A peer that stops draining its socket would leave send_text() pending +# forever — and since publishers await the fan-out gather, one stalled peer +# would stall every publisher's receive loop behind it. Bounding the send +# turns the stall into an eviction through the normal failed-send drop path. +SEND_TIMEOUT_SECONDS = 5.0 + +# RFC 6455 close codes. +_WS_UNSUPPORTED_DATA = 1003 # binary frame on a text-only relay +_WS_POLICY_VIOLATION = 1008 # invalid session id / rate cap exceeded +_WS_MSG_TOO_BIG = 1009 +_WS_TRY_AGAIN_LATER = 1013 # room or server at capacity + +# session_id → {socket: per-socket send lock}. The lock serializes concurrent +# fan-out sends to the same peer (two publishers relaying at once must not +# interleave writes on a third socket's transport). +_rooms: dict[str, dict[WebSocket, asyncio.Lock]] = {} + + +async def _send_locked(peer: WebSocket, lock: asyncio.Lock, text: str) -> None: + async with lock: + await asyncio.wait_for(peer.send_text(text), timeout=SEND_TIMEOUT_SECONDS) + + +@router.websocket("/ws/sync/{session_id}") +async def sync_ws(websocket: WebSocket, session_id: str): + """Join the fan-out room *session_id*; relay every inbound text frame.""" + await websocket.accept() + + if not _SESSION_ID_RE.fullmatch(session_id): + await websocket.close(code=_WS_POLICY_VIOLATION, reason="invalid session id") + return + + # Capacity checks and insertion run with no await between them, so + # concurrent joiners on the event loop can't race past the caps. + room = _rooms.get(session_id) + if room is None: + if len(_rooms) >= MAX_ROOMS: + await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="too many active sessions") + return + room = _rooms[session_id] = {} + log.debug("ws_sync: room %s created", session_id) + elif len(room) >= MAX_CLIENTS_PER_ROOM: + await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="session full") + return + room[websocket] = asyncio.Lock() + + tokens = RATE_BURST + last_refill = time.monotonic() + + try: + while True: + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + break + text = message.get("text") + if text is None: + await websocket.close(code=_WS_UNSUPPORTED_DATA, reason="text frames only") + break + if len(text.encode("utf-8", errors="ignore")) > MAX_FRAME_BYTES: + await websocket.close(code=_WS_MSG_TOO_BIG, reason="frame too large") + break + + now = time.monotonic() + tokens = min(RATE_BURST, tokens + (now - last_refill) * RATE_MSGS_PER_SEC) + last_refill = now + tokens -= 1.0 + if tokens < 0: + await websocket.close(code=_WS_POLICY_VIOLATION, reason="rate cap exceeded") + break + + peers = [(ws, lock) for ws, lock in room.items() if ws is not websocket] + if not peers: + continue + results = await asyncio.gather( + *(_send_locked(ws, lock, text) for ws, lock in peers), + return_exceptions=True, + ) + # A peer that failed mid-send is dropped from the room here; its + # own handler finishes cleanup (the finally below) when its + # receive loop observes the disconnect. + for (peer, _lock), result in zip(peers, results): + if isinstance(result, Exception): + room.pop(peer, None) + finally: + room.pop(websocket, None) + # Guard against deleting a NEW room another joiner created after this + # one emptied (only possible for a dict that is no longer ours). + if not room and _rooms.get(session_id) is room: + del _rooms[session_id] + log.debug("ws_sync: room %s closed", session_id) diff --git a/main.py b/main.py index 0673b53..6278097 100644 --- a/main.py +++ b/main.py @@ -44,6 +44,13 @@ def run() -> None: # record — including early startup messages — passes through the same # structured pipeline. log_config=None, + # Cap inbound WebSocket frames at the transport, before uvicorn + # materializes them in memory (its default is 16 MB). No client sends + # large frames to this server: the highway WS receives only small + # control messages, and the /ws/sync relay enforces its own tighter + # 16 KB application cap (routers/ws_sync.py MAX_FRAME_BYTES) — this is + # the defense-in-depth bound above it. + ws_max_size=64 * 1024, ) diff --git a/server.py b/server.py index ecb6863..a1aa1d9 100644 --- a/server.py +++ b/server.py @@ -49,7 +49,7 @@ import demo_mode import scan import tailwind_rebuild # Extracted route modules. They import `appstate`, never `server` — one-way graph. -from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics +from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, ws_sync, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics from routers import tunings as tunings_router import enrichment from routers import art as art_router @@ -1618,6 +1618,12 @@ app.include_router(media_router.router) app.include_router(ws_highway.router) +# ── Session-sync relay WebSocket (feedBack#1030) ───────────────────────────── +# Dumb JSON fan-out rooms for cross-device followers (splitscreen LAN mode). +# Implementation in lib/routers/ws_sync.py. +app.include_router(ws_sync.router) + + # ── Audio serving ───────────────────────────────────────────────────────────── diff --git a/tests/test_ws_sync.py b/tests/test_ws_sync.py new file mode 100644 index 0000000..a42c21d --- /dev/null +++ b/tests/test_ws_sync.py @@ -0,0 +1,234 @@ +"""Tests for the session-sync relay WebSocket (/ws/sync/{session_id}). + +Behavior tests run against a minimal FastAPI app carrying just the router +(fast — no full-server import); one integration test imports the real server +to pin that the route is actually mounted there. + +Covers the feedBack#1030 acceptance list: bidirectional fan-out, late join, +sender never echoed, room garbage collection, and the limit closes (invalid +session id, binary frames, frame size, room size, room count, rate cap) — +including that one client tripping a limit doesn't disturb the others. +""" + +from __future__ import annotations + +import asyncio +import importlib +import sys +import time + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from routers import ws_sync + + +@pytest.fixture(autouse=True) +def _clean_rooms(): + ws_sync._rooms.clear() + yield + ws_sync._rooms.clear() + + +@pytest.fixture() +def client(): + app = FastAPI() + app.include_router(ws_sync.router) + with TestClient(app) as c: + yield c + + +def _expect_close(ws, code): + with pytest.raises(WebSocketDisconnect) as exc: + ws.receive_text() + assert exc.value.code == code + + +# ── Fan-out semantics ──────────────────────────────────────────────────────── + +def test_two_clients_relay_both_directions_and_no_echo(client): + with client.websocket_connect("/ws/sync/ROOM01") as a, \ + client.websocket_connect("/ws/sync/ROOM01") as b: + a.send_text('{"type":"time","t":1.5}') + assert b.receive_text() == '{"type":"time","t":1.5}' + b.send_text('{"type":"hello"}') + # A's first inbound frame is B's hello — NOT an echo of its own send. + assert a.receive_text() == '{"type":"hello"}' + + +def test_late_joiner_receives_subsequent_frames(client): + with client.websocket_connect("/ws/sync/ROOM02") as a, \ + client.websocket_connect("/ws/sync/ROOM02") as b: + a.send_text("f1") + assert b.receive_text() == "f1" + with client.websocket_connect("/ws/sync/ROOM02") as c: + a.send_text("f2") + assert b.receive_text() == "f2" + assert c.receive_text() == "f2" + + +def test_rooms_are_isolated(client): + with client.websocket_connect("/ws/sync/ROOMA1") as a, \ + client.websocket_connect("/ws/sync/ROOMB1") as b, \ + client.websocket_connect("/ws/sync/ROOMA1") as a2: + a.send_text("for-room-a") + assert a2.receive_text() == "for-room-a" + # B (other room) got nothing: prove it by relaying within B's room. + with client.websocket_connect("/ws/sync/ROOMB1") as b2: + b2.send_text("for-room-b") + assert b.receive_text() == "for-room-b" + + +def test_client_disconnect_does_not_disrupt_remaining(client): + with client.websocket_connect("/ws/sync/ROOM03") as a, \ + client.websocket_connect("/ws/sync/ROOM03") as b: + with client.websocket_connect("/ws/sync/ROOM03") as c: + a.send_text("before") + assert b.receive_text() == "before" + assert c.receive_text() == "before" + # C is gone; relay between A and B continues. + a.send_text("after") + assert b.receive_text() == "after" + + +def test_room_garbage_collected_when_last_client_leaves(client): + with client.websocket_connect("/ws/sync/ROOM04") as a: + with client.websocket_connect("/ws/sync/ROOM04") as b: + a.send_text("x") + assert b.receive_text() == "x" + assert "ROOM04" in ws_sync._rooms + assert "ROOM04" not in ws_sync._rooms + assert ws_sync._rooms == {} + + +# ── Limit enforcement ──────────────────────────────────────────────────────── + +@pytest.mark.parametrize("bad_id", ["abc", "x" * 65, "has space", "bad$id", "nope!"]) +def test_invalid_session_id_closed_with_policy_code(client, bad_id): + with client.websocket_connect(f"/ws/sync/{bad_id}") as ws: + _expect_close(ws, 1008) + assert ws_sync._rooms == {} + + +def test_binary_frame_closes_with_unsupported_data(client): + with client.websocket_connect("/ws/sync/ROOM05") as ws: + ws.send_bytes(b"\x00\x01") + _expect_close(ws, 1003) + + +def test_oversized_frame_closes_sender_only(client): + with client.websocket_connect("/ws/sync/ROOM06") as a, \ + client.websocket_connect("/ws/sync/ROOM06") as b, \ + client.websocket_connect("/ws/sync/ROOM06") as c: + a.send_text("x" * (ws_sync.MAX_FRAME_BYTES + 1)) + _expect_close(a, 1009) + # The room carries on without A. + b.send_text("still-alive") + assert c.receive_text() == "still-alive" + + +def test_room_client_cap(client, monkeypatch): + monkeypatch.setattr(ws_sync, "MAX_CLIENTS_PER_ROOM", 2) + with client.websocket_connect("/ws/sync/ROOM07") as a, \ + client.websocket_connect("/ws/sync/ROOM07") as b, \ + client.websocket_connect("/ws/sync/ROOM07") as c: + _expect_close(c, 1013) + a.send_text("two-is-fine") + assert b.receive_text() == "two-is-fine" + + +def test_total_room_cap(client, monkeypatch): + monkeypatch.setattr(ws_sync, "MAX_ROOMS", 1) + with client.websocket_connect("/ws/sync/ROOM08"): + with client.websocket_connect("/ws/sync/ROOM09") as overflow: + _expect_close(overflow, 1013) + # Joining the EXISTING room is still fine at the room cap. + with client.websocket_connect("/ws/sync/ROOM08"): + pass + + +def test_rate_cap_closes_flooding_sender(client, monkeypatch): + monkeypatch.setattr(ws_sync, "RATE_BURST", 3.0) + monkeypatch.setattr(ws_sync, "RATE_MSGS_PER_SEC", 0.0) + with client.websocket_connect("/ws/sync/ROOM10") as a, \ + client.websocket_connect("/ws/sync/ROOM10") as b: + for i in range(3): + a.send_text(f"burst-{i}") + for i in range(3): + assert b.receive_text() == f"burst-{i}" + a.send_text("one-too-many") + _expect_close(a, 1008) + # The over-limit frame was dropped, not relayed, and B lives on. + with client.websocket_connect("/ws/sync/ROOM10") as c: + c.send_text("fresh-socket") + assert b.receive_text() == "fresh-socket" + + +class _StalledPeer: + """A fake room member whose send never completes (peer stopped draining).""" + + async def send_text(self, text): + await asyncio.Event().wait() + + +def test_stalled_peer_is_evicted_and_healthy_peers_still_receive(client, monkeypatch): + monkeypatch.setattr(ws_sync, "SEND_TIMEOUT_SECONDS", 0.2) + with client.websocket_connect("/ws/sync/ROOM11") as a, \ + client.websocket_connect("/ws/sync/ROOM11") as b: + # Wait for both handlers to have registered in the room, then inject + # the stalled peer directly (a real stalled TCP peer isn't + # constructible under TestClient). + deadline = time.monotonic() + 2.0 + while len(ws_sync._rooms.get("ROOM11", {})) < 2: + assert time.monotonic() < deadline, "room never filled" + time.sleep(0.01) + stalled = _StalledPeer() + ws_sync._rooms["ROOM11"][stalled] = asyncio.Lock() + + # Healthy delivery is not blocked behind the stalled peer, and by the + # time a second frame has round-tripped, the first fan-out's timeout + # has fired and evicted it. + a.send_text("f1") + assert b.receive_text() == "f1" + a.send_text("f2") + assert b.receive_text() == "f2" + assert stalled not in ws_sync._rooms["ROOM11"] + + +def test_main_run_caps_uvicorn_ws_max_size(): + """main.py must bound inbound WS frames at the transport (uvicorn defaults + to 16 MB, which would let a client materialize frames far past the relay's + 16 KB application cap before the handler ever sees them).""" + import unittest.mock + + import main + + with ( + unittest.mock.patch("logging_setup.configure_logging"), + unittest.mock.patch("uvicorn.run") as mock_run, + ): + main.run() + + kwargs = mock_run.call_args.kwargs + assert kwargs.get("ws_max_size") == 64 * 1024 + assert kwargs["ws_max_size"] >= ws_sync.MAX_FRAME_BYTES + + +# ── Real-app integration ───────────────────────────────────────────────────── + +def test_route_mounted_on_real_server(tmp_path, monkeypatch): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc")) + monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1") + sys.modules.pop("server", None) + server = importlib.import_module("server") + monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None) + monkeypatch.setattr(server, "startup_scan", lambda: None) + + with TestClient(server.app) as client: + with client.websocket_connect("/ws/sync/REALAPP") as a, \ + client.websocket_connect("/ws/sync/REALAPP") as b: + a.send_text('{"type":"time","t":0}') + assert b.receive_text() == '{"type":"time","t":0}'