Operating the panel & broadcaster
This page collects operational lore for running the demo/dev stack. The full reference is the Operator guide and Content management.
Broadcaster env for ingesting an external origin
Run node src/index.js from broadcaster/ with:
INPUT=<hls/rtsp url or file>— the origin to ingest (ffmpeg must be on PATH)STREAM_ID=<catalog id>PANEL_PUBKEY=<panel public key>PUBLISHER_KEY=<secretKey from the panel's DATA_DIR/keys/publisher.json>(written byadmin-cli init; registration is refused without it)
Re-registering a stream silently clobbers its curated title/category
- Cause: the
registerRPC merges the catalog record, but the broadcaster always sends a title and a category array. LeavingTITLE/CATEGORYunset overwrites curated values — title falls back to the stream id, category to[]. - Fix: always set
TITLEandCATEGORYwhen re-registering an existing stream. Poster/backdrop/logo art is preserved by register.
Users "have access" but can't decrypt after attaching a broadcaster
- Cause: user grants seal the stream's encryption key. A fresh broadcaster generates a NEW key and registers it — silently invalidating every existing grant.
- Fix: when attaching a broadcaster to a stream created via
admin-cli add-stream, copy the stream secret from the panel's privateDATA_DIR/secrets/streams.jsoninto the broadcaster'sdata/feed.keybefore first start. (Alternative: re-runadmin-cli grantfor every user afterwards.)
ELOCKED: File is locked starting the panel or admin-cli
- admin-cli and the panel cannot run concurrently (same Corestore): stop the panel, run admin-cli, restart the panel. Registration, by contrast, requires the running panel.
- Right after killing a panel process, a restart can still hit
ELOCKEDfor a few seconds until the dead process's file locks release — retry, don't debug.
Dev processes rot
Long-running dev panel/broadcaster processes wedge silently:
- An hours-old panel can stop answering DHT connects while still
looking alive in the process list. Health check: a small hyperswarm read
of
catalog/*from another machine (seconds when healthy). Restarting the panel is always safe. - The broadcaster's ffmpeg can die without a log line while the
broadcaster keeps "seeding" a frozen playlist. Health check: is ffmpeg
running, and is the HLS temp dir's mtime advancing? Restarting the
broadcaster is always safe (feed identity persists in its data dir —
grants stay valid if
feed.keyis present). - Clients that were connected when the panel bounced may need an app restart to shed stale swarm state (see the login-stall entry in playback).
Every channel shows registered:false after a panel restart
- Symptom: the panel and broadcaster restarted around the same time
(
docker compose up -d --build, or a host reboot, for example); streams keep playing, but every channel sits atregistered:falseand the panel catalog stops tracking liveness. Current builds say why —registerError: "no panel connection for Ns"; older builds showed a silentregisterError: null. - Cause: the panel's swarm identity is ephemeral — a restarted panel announces the registration topic under a brand-new keypair. A broadcaster that resolved the topic just before (or during) the restart holds a dead peer record, and hyperswarm re-queries a client-mode topic only every ~10 minutes on its own.
- Fix: none needed on current builds. While registrations are
stranded with no panel socket, the broadcaster forces fresh topic
lookups (5 s → 60 s backoff) and re-registers as soon as the new
announce lands — typically well under a minute. If
registeredstays false for several minutes anyway (or you run a pre-hardening build), restart the broadcaster. Either way playback is unaffected: running feeds keep streaming, and only catalog liveness/registration lags.
Control API dead + all viewers frozen after a few login attempts
- Symptom (pre-fix builds): a handful of
POST /api/loginattempts against the broadcaster control API — wrong passwords included — and the whole broadcaster stops: control API times out, swarm replication stalls (every viewer freezes into the tune-timeout error), and it never recovers on its own. Observed in production 2026-07-16 on a 1 GB host deep in swap: 4-5 attempts caused 25+ minutes dead, and a container restart was required. - Cause: Argon2id password verification is memory-hard (64 MiB per verify at the default cost) and ran synchronously on the node event loop. The login throttle counts attempts per window but doesn't stop them queueing, so each verify blocked the loop back-to-back — and on a swap-thrashing box one verify grinds for minutes.
- Fix: none needed on current builds. Verification runs in a worker
thread, strictly one at a time: a concurrent login is rejected
503immediately, and a verify that outlives its budget (loginVerifyTimeoutMs, default 30 s) fails503with the worker terminated and respawned. Media and the rest of the control API stay responsive throughout (regression:npm run test:login-flood). On a pre-fix build: restart the broadcaster container and keep the control port firewalled to localhost/TLS-proxy only (its default binding). - Panel too: the panel's admin login (the dashboard's
POST /api/login) shared the defect, with a worse blast radius — the panel event loop also drives catalog replication and the viewer login RPC, so a pre-fix panel under login flood freezes every fresh app login in the fleet. Same fix, same semantics (panel/src/ops.js makeAdminVerifier; regression:npm run test:panel-login-flood). On a pre-fix build: restart the panel container. The viewer OPRF login path itself never had the problem, because its Argon2 grind runs on the client. - Ops note: repeated
503on login means verifies are being rejected or timing out — on a small host that's your swap-pressure alarm; check RAM before blaming auth. LowerARGON2_MEM_KIBfor boxes this tight (it guards a localhost-only admin API; existing admins keep their recorded cost until the password is rotated).
Identifying which process is which
The panel and broadcaster both run as node src/index.js — the command
line alone won't tell them apart. Distinguish by working directory or
parent process, or when hunting a stuck ffmpeg, match its command line by
the HLS output directory it writes.
A channel shows colour bars / "SOURCE OFFLINE" instead of its content
This is working as designed. When a source fails past SLATE_AFTER
consecutive respawns (default 3, counted per configured fallback url), the
broadcaster loops a pre-rendered slate so the channel stays live with a
clear message rather than sitting blank in watchdog backoff. It is NOT
stuck: every SLATE_RETRY_MS (default 30 s) it drops the slate and
re-probes the real source, and it returns on its own the moment the source
comes back — no operator action needed.
The trap for operators: a slated channel reports state: up and looks
healthy by every other measure (ffmpeg alive, live edge advancing, peers
connected), because it genuinely is — bars are flowing. To tell "showing
the source" from "showing bars," check slate.slated in GET
/api/channels/:id, or watch for the slate-on / slate-retry entries in
the incident log. If a channel is permanently slated, the source itself
is down — check its ffmpeg log ring for why. Set SLATE_ENABLED=false to
revert to the old blank-during-backoff behaviour. Full detail:
Offline slate media.
Monitoring says the control API is down right after a deploy
This is expected for a few moments on a full-fleet recreate, and it is
now bounded. On restart the broadcaster auto-resumes every
desired-running channel; each one's one-time startup cost (open the
store, reconcile the drive, join the swarm, spawn ffmpeg) lands on the
single Node event loop, so a dense enough resume can starve the
authenticated /api for the duration of the ramp. At ~83 channels an
unpaced resume blacked /api out for ~7 minutes.
Two things fix this, both on by default:
RESUME_PACE(default on) paces the resume — it waits for the event loop to catch up between channel starts, so/apiand swarm replication stay responsive throughout. It is adaptive, so it barely slows a small fleet.RESUME_PACE=0restores the old back-to-back behaviour.GET /healthzis unauthenticated and cheap, served before the auth gate, and reports{up, resuming, resumed, total, ...}. Point your uptime check and alerting at/healthz, not/api/status—/api/statusneeds a token and does real work, so it is the wrong probe for "is the process alive". During a resume/healthzshowsresuming:true, resumed:45, total:83— progress, not a dead socket.
If /healthz itself is unreachable, the process is genuinely down (or
the port is blocked) — that is the signal to act on.
Every HTTP surface (panel included) also serves unauthenticated
Prometheus-text GET /metrics beside its /healthz, and
LOG_FORMAT=json switches any service to one JSON object per log line —
see the operator guide's Monitoring
section for the scrape config and the
log-growth bounds.
Latency expectations (healthy system)
- First DHT connect from a fresh client store: 30–90 s; subsequent logins ~10 s.
- After play: a few seconds of playlist 404s while the live edge replicates.
1 peermeans the broadcaster only; more viewers means more seeders.- Time-to-play jumps back to 40–55 s after every broadcaster restart?
You're on
FEED_BUFFER=ram— each restart mints a new feed identity, so viewers re-pay a cold DHT discovery. Switch to the defaultFEED_BUFFER=diskfor a stable, warm topic. See P2P feed buffer & tuning.