Files
XC_VM/docs/xc_fanout.md
T
Divarion_D 881a2e795a feat(streaming): panel installer/updater for the xc_fanout daemon
The daemon moves to its own repo (XC_VM_Fanout, GIT_REPO_FANOUT) that ships
per-arch static binaries as GitHub Release assets. Since we're fully
migrating onto the daemon, the panel needs to fetch and update it.

- FanoutBinaryCommand (`console.php fanout_binary`): reads the installed
  version straight from the binary (`xc_fanout -version`), compares to the
  latest release, and when they differ downloads the arch-matched asset,
  verifies its SHA-256 against the release SHA256SUMS, installs it
  atomically and restarts the daemon (the service keepalive respawns it).
  `force` reinstalls the current version. Modelled on BinariesCommand.
- GIT_REPO_FANOUT constant.
- docs/xc_fanout.md: separate-repo + install/update section.

The daemon repo itself (source + release.sh + a v* release GitHub Action,
binaries gitignored) is prepared under XC_VM_Fanout/ for the user to push.
2026-08-16 20:22:34 +03:00

6.2 KiB

xc_fanout — the Go live-delivery daemon (why it exists)

xc_fanout is a small, native Go daemon that XC_VM uses to deliver live streams to viewers. This page explains why it exists and how the panel talks to it. The decision records are ADR 0001/0002/0003; this is the plain-language overview.

The daemon's source lives in its own repo, XC_VM_Fanout (GIT_REPO_FANOUT), and its compiled binaries ship as GitHub Release assets (never committed to a tree). It is a single static binary per architecture and is never bundled into the panel/LB archive.


The problem it solves

Historically PHP was in the byte path of every live viewer:

  • Proxy streams — Public/stream/live.php ran a socket_read → echo → flush loop for the whole session, one PHP-FPM worker pinned per viewer.
  • Non-proxy streams — live.php chase-read the HLS .ts segments off tmpfs and echoed them as a continuous MPEG-TS, again one worker per viewer.

We measured it (ADR 0001, P0): ~1 PHP-FPM worker + ~25 MB of RAM per viewer. That linear RAM law — not pm.max_children — is the real ~400-concurrent ceiling. A 4 GB box walls at ~55 viewers on RAM alone. Every viewer is a blocked, expensive worker instead of a cheap connection.

The whole redesign is about getting PHP out of the byte path so a viewer becomes a cheap nginx→daemon connection. The daemon is what holds those connections.


What the daemon does

One process serves many streams. Per stream it holds one ingest and fans it out to many viewers, plus segments the same feed into HLS entirely in RAM (no tmpfs, no files):

  • Live TS fan-out — GET /live/<id>: PAT/PMT + keyframe-aware ring buffer, each new viewer gets a clean-join snapshot then the live tail; slow viewers are dropped, never blocking the producer or others.
  • In-RAM HLS — GET /hls/<id>/index.m3u8 and /hls/<id>/<seq>.ts: cuts segments at video keyframes with PTS-accurate EXTINF, sliding window; can AES-128-CBC encrypt segments (matching the panel's encrypt_hls).
  • Two ways to be fed:
    • pull (proxy streams) — the daemon connects to the source itself (direct video/mp2t, or spawns ffmpeg to remux other inputs). Replaces Cli/Commands/ProxyCommand.php.
    • push / ingest (non-proxy & LLOD) — the stream's existing producer writes MPEG-TS into a per-stream unix socket the daemon listens on. The producer's real work (transcode, logo, profiles) stays where it was.
  • On-demand lifecycle — starts a puller on the first viewer, stops it after a grace once the last leaves; HLS requests keep it alive too.

Two unix-socket HTTP surfaces: a client surface nginx proxies viewers to, and a control surface only the panel (PHP) talks to.


What the daemon does NOT do

  • It does not replace ffmpeg. For non-proxy streams the per-stream ffmpeg keeps doing the real work (transcode, logo overlay, profiles, timeshift recording). The daemon only takes over delivery.
  • It does not do auth. PHP still authenticates every request (tokens, line limits, IP match, connection tracking). The daemon only moves bytes.
  • It does not touch the database. All DB writes stay in PHP — this keeps LB nodes privilege-free.
  • Live only. VOD, series, timeshift/archive are random-access (seek / byte-range) over files, not a shared live tail — they keep their own path.

How the panel talks to it

The switch is the daemon's reachability, not a settings flag: live.php routes a stream to the daemon when its control socket is present and the stream is being served; otherwise it runs the legacy path. Stopping the daemon is the rollback — every stream falls back automatically, per node.

Delivery uses the same X-Accel-Redirect pattern as P1 HLS: PHP authenticates, then hands the byte path to nginx, which proxies to the daemon. The FPM worker is freed the instant PHP returns.

Client surface (nginx → daemon, via internal X-Accel locations):

Request Daemon route
live TS X-Accel-Redirect: /xc_fanout/<id>?c=<uuid> → /live/<id>
HLS segment X-Accel-Redirect: /xc_fanout_hls/<id>_<seq> → /hls/<id>/<seq>.ts
HLS playlist PHP fetches /hls/<id>/index.m3u8, tokenizes the segment URLs

Control surface (PHP → daemon, FanoutClient over the control unix socket):

Call Purpose
PUT /streams/<id> register a proxy source (urls, ua, proxy, cookie, ffmpeg, key/iv)
PUT /ingest/<id> create the push ingest socket for a non-proxy/LLOD producer (+ key/iv)
POST /probe/<id>?wait= prewarm + wait for first data → off-air detection
GET /streams/<id> status (running, has_data, since_data_ms)
GET /connections active live-TS viewer uuids (for fanout_sync reconcile)
DELETE /streams/<id> stop / drop the stream

FanoutClient (src/Streaming/Fanout/FanoutClient.php) is the PHP client. The fanout_sync daemon (console.php fanout_sync) reconciles connection records against GET /connections so a viewer's disconnect frees their line's slot (under X-Accel, PHP never sees the disconnect itself).


Where it runs

Supervised from src/service (a keepalive loop restarts it on crash). Its sockets live next to the binary in the app bin tree (bin/xc_fanout/sockets/{http,control}.sock), mirroring the php-fpm sockets layout nginx already reaches over unix:. Installed on MAIN first; LB rollout is a later phase.

Install / update

The binary is fetched from the latest XC_VM_Fanout release, like the other distributed binaries:

console.php fanout_binary          # install or update to the latest release
console.php fanout_binary force     # reinstall the current version

FanoutBinaryCommand reads the installed version straight from the binary (xc_fanout -version), compares it to the latest release tag, and — when they differ — downloads the arch-matched asset (xc_fanout-linux-<arch>), verifies its SHA-256 against the release's SHA256SUMS, installs it atomically, and kills the running daemon so the service keepalive respawns it with the new binary.

See docs/adr/0001-*, 0002-*, 0003-* for the full design and the phased cutover plan.