From 4ec81cbb2fad057bdec65f75eb39caecc595ad54 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:34:59 +0000 Subject: [PATCH] Stability: drop ServerQuery-only register call + cap viewer default Two unrelated stability fixes that surfaced after the live test of identity persistence (PR #17): * `register_stream_notifications` was sending three `servernotifyregister` calls during stream setup; each got a `error id=516 invalid client type` back from the server because that command is for ServerQuery clients (type 1) only. As a voice client (type 0) we receive stream + channel notifications automatically, so the call is dead weight that polluted the log on every startup. Drop the call site and the method; keep a comment so the next reader doesn't re-add it from the upstream ts6-manager port (which is a ServerQuery client and does need it). * Each viewer spins up its own VP8 encoder via the per-viewer `RTCPeerConnection` + MediaRelay subscription. Live measurement on 720p30 showed RSS jumping from ~110 MB (idle) to ~400 MB after the first viewer connected - the unlimited default (`STREAM_VIEWER_LIMIT=-1`) would silently OOM-kill the container on a small host once a fourth or fifth viewer joined. Default cap to 4; the operator can bump it once they've measured. --- .env.example | 11 +++++++---- src/ts6_stream_bot/config.py | 9 +++++++-- src/ts6_stream_bot/pipeline/controller.py | 1 - src/ts6_stream_bot/pipeline/stream_signaling.py | 11 +++++++---- tests/test_stream_signaling.py | 9 --------- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 1103a1c..d6d0cad 100644 --- a/.env.example +++ b/.env.example @@ -52,15 +52,18 @@ TS6_CHANNEL_PASSWORD= # - STREAM_MODE: 1 = request-based (server forwards notifyjoinstreamrequest # for each viewer and the bot replies via respondjoinstreamrequest); # 0 = auto-accept. -# - STREAM_VIEWER_LIMIT: -1 = unlimited (TS3 convention). 0 is interpreted -# by some TS6 builds as "no viewers allowed", which silently drops every -# join attempt. +# - STREAM_VIEWER_LIMIT: each viewer spins up its own VP8 encoder +# (~280 MB RSS at 720p30) so unlimited can OOM a small host. The default +# of 4 is conservative; bump it once you've measured memory under load. +# -1 = unlimited (TS3 convention). 0 is interpreted by some TS6 builds +# as "no viewers allowed", which silently drops every join attempt - +# don't use 0. # - STREAM_BITRATE: hint to the server (kbps); 4608 mirrors the default the # TS6 client uses for "high quality" screen share. STREAM_BITRATE=4608 STREAM_ACCESSIBILITY=0 STREAM_MODE=1 -STREAM_VIEWER_LIMIT=-1 +STREAM_VIEWER_LIMIT=4 # --- WebRTC ICE servers ---------------------------------------------------- # STUN exposes the bot's public-NAT'd IP as a server-reflexive candidate; diff --git a/src/ts6_stream_bot/config.py b/src/ts6_stream_bot/config.py index f89c738..a719872 100644 --- a/src/ts6_stream_bot/config.py +++ b/src/ts6_stream_bot/config.py @@ -108,8 +108,13 @@ class Settings(BaseSettings): ), ) STREAM_VIEWER_LIMIT: int = Field( - default=-1, - description="Max viewers; -1 = unlimited (TS3 convention).", + default=4, + description=( + "Max concurrent viewers. Each viewer spins up its own VP8 encoder " + "(~280 MB RSS at 720p30); without a cap the bot can OOM the host " + "before the operator notices. -1 = unlimited (TS3 convention) - " + "only set this if you've measured memory under load." + ), ) # --- WebRTC ICE ------------------------------------------------------- diff --git a/src/ts6_stream_bot/pipeline/controller.py b/src/ts6_stream_bot/pipeline/controller.py index 3047f49..c07d616 100644 --- a/src/ts6_stream_bot/pipeline/controller.py +++ b/src/ts6_stream_bot/pipeline/controller.py @@ -245,7 +245,6 @@ async def _allocate_stream(self) -> None: return signaling = StreamSignaling(self._ts3_client) - signaling.register_stream_notifications() capture_config = VideoCaptureConfig( display=settings.DISPLAY, diff --git a/src/ts6_stream_bot/pipeline/stream_signaling.py b/src/ts6_stream_bot/pipeline/stream_signaling.py index 1a265aa..38d3d2e 100644 --- a/src/ts6_stream_bot/pipeline/stream_signaling.py +++ b/src/ts6_stream_bot/pipeline/stream_signaling.py @@ -285,10 +285,13 @@ def _stream_from_params(p: dict[str, str], *, audio_key: str) -> ActiveStream: # --- outbound commands ------------------------------------------------ - def register_stream_notifications(self) -> None: - """Subscribe to the server-side notifications we need. Idempotent.""" - for event in ("channel", "server", "textchannel"): - self._client.send_command(build_command("servernotifyregister", {"event": event})) + # Note: voice clients (which is what we are) receive stream-related + # notifications automatically by virtue of being in the channel; the + # ``servernotifyregister`` command is for ServerQuery clients only and + # the TS6 server replies with ``error id=516 invalid client type`` if + # we send it. The ts6-manager port we started from registered three + # events here because it ran as a ServerQuery client - that doesn't + # apply to us. def send_setup_stream( self, diff --git a/tests/test_stream_signaling.py b/tests/test_stream_signaling.py index d46fc13..cba94e1 100644 --- a/tests/test_stream_signaling.py +++ b/tests/test_stream_signaling.py @@ -241,15 +241,6 @@ def test_send_stream_stop_and_remove_client() -> None: assert rm_cmd.params["clid"] == "42" -def test_register_stream_notifications_subscribes_to_three_events() -> None: - client = _FakeClient() - sig = StreamSignaling(client) # type: ignore[arg-type] - sig.register_stream_notifications() - - events = [parse_command(c).params["event"] for c in client.sent] - assert events == ["channel", "server", "textchannel"] - - # --- chaining with prior on_command ---------------------------------------