From 03e1c1d57e60ab6c61e5ba8dc9e09294e71ed493 Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Wed, 22 Jul 2026 15:45:56 -0400 Subject: [PATCH] feat(server): session-sync relay WebSocket /ws/sync/{session_id} (#1030) (#1032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(server): add session-sync relay WebSocket /ws/sync/{session_id} Cross-device followers (splitscreen's upcoming LAN pop-out mode, feedBack-plugin-splitscreen#21) need a machine-crossing replacement for BroadcastChannel — the one link in the follower architecture that cannot leave the host browser. Chart data already streams per-client over /ws/highway, so all that's missing is a dumb live-state channel. Add a fan-out room endpoint: a JSON text frame from one client is relayed verbatim to every other client on the same session id. No schema, no history, no persistence — rooms are created on first join and GC'd when the last socket leaves. The statelessness is deliberate: an idle room is indistinguishable from a nonexistent one, and a host that crashes and rejoins the same id resumes publishing to reconnecting subscribers with no server-side coordination. Caps for a LAN-exposable port: 16 KB frames (1009), 16 sockets/room and 32 rooms (1013), 120 msg/s sustained / 240 burst per socket (1008), text-only (1003), session id validated against [A-Za-z0-9_-]{4,64}. An over-limit socket is closed individually; a peer that dies mid-fan-out is dropped without wedging delivery to the rest. Closes #1030 Co-Authored-By: Claude Fable 5 Signed-off-by: Kris Anderson * fix(ws_sync): bound stalled peer sends; cap ws frames at the transport Review feedback (CodeRabbit on #1032): - A peer that stops draining its socket left send_text() pending forever; since publishers await the fan-out gather, one stalled peer stalled every publisher's receive loop behind it. Fan-out sends are now bounded by SEND_TIMEOUT_SECONDS (5 s) so a stall becomes an eviction through the existing failed-send drop path. - uvicorn buffers inbound WS frames up to its 16 MB default before the handler's 16 KB check ever runs, so the DoS bound wasn't enforced at the transport. main.py now passes ws_max_size=64 KB (no client sends large frames: the highway WS receives only small control messages, and the relay keeps its tighter application cap as the primary limit). Regression tests for both; the desktop's own uvicorn spawn gets the matching --ws-max-size flag with the feedBack-desktop follow-up work. Co-Authored-By: Claude Fable 5 Signed-off-by: Kris Anderson --------- Signed-off-by: Kris Anderson Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 18 ++++ lib/routers/ws_sync.py | 140 ++++++++++++++++++++++++ main.py | 7 ++ server.py | 8 +- tests/test_ws_sync.py | 234 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 lib/routers/ws_sync.py create mode 100644 tests/test_ws_sync.py 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}'