From 26f4623e959e346232814ba06ee70464cd090e8b Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 01:02:05 +0000 Subject: [PATCH 01/37] =?UTF-8?q?feat(health):=20read-only=20Health=20Skin?= =?UTF-8?q?=20=E2=80=94=20grain=20flow=20(+unique-fps),=20system,=20pipeli?= =?UTF-8?q?ne=20writers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Skin over the same Core, deliberately separate from the control Skin: - GET /api/mxl/health + GET /health (read-only; no mutation path) - backend/health-info.js: pure aggregation — classifies flows ok/slow/frozen/dead. 'frozen' = grains tick at rate but unique-fps collapsed (repeat-wedge detection). - Reuses tools/grain_probe.py's grains.json snapshot (already load-guarded) + /proc + the 3 pipeline/status endpoints; 2s server cache so N viewers = one scrape. No docker exec, no probe spawning, live PGM path untouched. - web/health.html: air-gapped (self-hosted fonts), 3s poll, Skin-styled. - Tests: 14 node unit tests + 5 python (air-gap, read-only, separation-from-Core). Full suite green (50 node / 139 py). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- backend/health-info.js | 160 +++++++++++++++++++++++++ backend/local-server.js | 64 ++++++++++ docs/HEALTH-SKIN.md | 60 ++++++++++ tests/js/health-info.test.js | 119 +++++++++++++++++++ tests/test_local_ui.py | 50 ++++++++ web/health.html | 218 +++++++++++++++++++++++++++++++++++ 6 files changed, 671 insertions(+) create mode 100644 backend/health-info.js create mode 100644 docs/HEALTH-SKIN.md create mode 100644 tests/js/health-info.test.js create mode 100644 web/health.html diff --git a/backend/health-info.js b/backend/health-info.js new file mode 100644 index 0000000..ef86dd7 --- /dev/null +++ b/backend/health-info.js @@ -0,0 +1,160 @@ +// backend/health-info.js — read-only health aggregation for the Health Skin. +// +// This is deliberately SEPARATE from the control plane (mxl-routes.js). The Core +// Skin (web/local.html) is what a TD touches on air — cut/arm/take. Grain +// internals, CPU/mem and pipeline-writer state belong on their own read-only +// page (web/health.html, served at /health). Nothing here mutates the facility. +// +// Cost note: the expensive part — measuring per-flow grain rate AND unique-frames +// /s (to catch repeat-wedges where the head advances but the picture is frozen) — +// is already done by tools/grain_probe.py, which writes a rolling-window snapshot +// to /grains.json every 3s and has hard load guards (pauses >26, exits +// >29). We only READ that file here, so N health viewers add ~one file read each, +// never a container spawn. The live PGM/encode path is never touched. + +'use strict'; + +const fs = require('fs'); + +// ── System: CPU / mem / load from /proc (Linux). Pure reads, microseconds. ── +// CPU % needs two samples of /proc/stat; the caller holds the previous snapshot +// so a single scrape is cheap and the delta is computed across polls. +function readProcStat(readFile = fs.readFileSync) { + // Returns {total, idle} jiffies for the aggregate "cpu " line, or null. + try { + const line = String(readFile('/proc/stat', 'utf8')).split('\n') + .find((l) => l.startsWith('cpu ')); + if (!line) return null; + const n = line.trim().split(/\s+/).slice(1).map(Number); + // user nice system idle iowait irq softirq steal ... + const idle = (n[3] || 0) + (n[4] || 0); // idle + iowait + const total = n.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0); + return { total, idle }; + } catch { return null; } +} + +function cpuPercent(prev, cur) { + if (!prev || !cur) return null; + const dt = cur.total - prev.total; + const di = cur.idle - prev.idle; + if (dt <= 0) return null; + return Math.max(0, Math.min(100, Math.round((1 - di / dt) * 100))); +} + +function readMem(readFile = fs.readFileSync) { + try { + const info = {}; + String(readFile('/proc/meminfo', 'utf8')).split('\n').forEach((l) => { + const m = l.match(/^(\w+):\s+(\d+)/); + if (m) info[m[1]] = Number(m[2]); // kB + }); + const totalKb = info.MemTotal || 0; + const availKb = info.MemAvailable != null ? info.MemAvailable : info.MemFree || 0; + if (!totalKb) return null; + const usedKb = totalKb - availKb; + return { + total_mb: Math.round(totalKb / 1024), + used_mb: Math.round(usedKb / 1024), + used_pct: Math.round((usedKb / totalKb) * 100), + }; + } catch { return null; } +} + +function readLoad(readFile = fs.readFileSync) { + try { + const p = String(readFile('/proc/loadavg', 'utf8')).trim().split(/\s+/); + return { load1: Number(p[0]), load5: Number(p[1]), load15: Number(p[2]) }; + } catch { return null; } +} + +function cpuCount(readFile = fs.readFileSync) { + try { + return String(readFile('/proc/stat', 'utf8')).split('\n') + .filter((l) => /^cpu\d+ /.test(l)).length || null; + } catch { return null; } +} + +// ── Grain flow: read grain_probe's snapshot (already on disk, atomic-renamed). ─ +// Shape written by tools/grain_probe.py: {ts, flows: {name: {bps, ufps}|null}}. +// We classify each flow so the UI can paint green/amber/red without logic: +// dead — null (probe read nothing in the window) +// frozen — bps healthy but ufps collapsed (repeat-wedge: head moves, picture +// doesn't). This is the exact keyer-bypass symptom we hit live. +// ok — bps and ufps both near the expected rate +// expectedRate defaults to 30 (the facility is 1080p30). stale = snapshot older +// than staleAfterS (probe died / not running). +function classifyGrains(snapshot, { expectedRate = 30, nowS, staleAfterS = 12 } = {}) { + if (!snapshot || typeof snapshot !== 'object' || !snapshot.flows) { + return { stale: true, age_s: null, flows: {} }; + } + const now = nowS != null ? nowS : Date.now() / 1000; + const ageS = snapshot.ts != null ? Math.max(0, now - snapshot.ts) : null; + const stale = ageS == null ? true : ageS > staleAfterS; + const flows = {}; + for (const [name, v] of Object.entries(snapshot.flows)) { + if (!v || v.bps == null) { + flows[name] = { state: 'dead', bps: 0, ufps: 0 }; + continue; + } + const bps = v.bps; + const ufps = v.ufps != null ? v.ufps : null; + // frozen: grains tick at ~rate but unique frames are a fraction of them. + // Use 60% of expected as the unique-fps floor for "moving". + let state = 'ok'; + if (bps < expectedRate * 0.5) state = 'slow'; + if (ufps != null && ufps < expectedRate * 0.6 && bps >= expectedRate * 0.5) state = 'frozen'; + flows[name] = { state, bps, ufps }; + } + return { stale, age_s: ageS != null ? Math.round(ageS * 10) / 10 : null, flows }; +} + +function readGrainsSnapshot(grainsPath, readFile = fs.readFileSync) { + try { return JSON.parse(String(readFile(grainsPath, 'utf8'))); } + catch { return null; } +} + +// ── Pipeline writers: normalize the three pipeline/status payloads for the UI. ─ +// Pure — the caller fetches the JSON (or passes nulls when a port is unreachable) +// and we shape it. No network here, so it's unit-testable. +function shapePipelines({ selector, keyer, encoder } = {}) { + const sel = selector || {}; + const key = keyer || {}; + const enc = encoder || {}; + const fmt = (f) => (f && f.format + ? { + w: f.format.frame_width, h: f.format.frame_height, + rate: f.format.grain_rate + ? Math.round((f.format.grain_rate.numerator || 0) / (f.format.grain_rate.denominator || 1)) + : null, + } + : null); + return { + selector: selector ? { + running: !!sel.running, error: sel.error || null, + active_input: sel.active_input != null ? sel.active_input : null, + inputs: Array.isArray(sel.input_flow_uuids) ? sel.input_flow_uuids.length : null, + format: fmt(sel), + } : { running: false, unreachable: true }, + keyer: keyer ? { + running: !!key.running, error: key.error || null, + key_on: !!key.key_on, mode: key.mode || null, + format: fmt(key), + } : { running: false, unreachable: true }, + encoder: encoder ? { + running: !!enc.running, error: enc.error || null, + viewers: enc.viewers != null ? enc.viewers : null, + bitrate_kbps: enc.encoder && enc.encoder.bitrate != null ? enc.encoder.bitrate : null, + tune: enc.encoder ? enc.encoder.tune : null, + } : { running: false, unreachable: true }, + }; +} + +// ── Compose the whole payload (pure; takes already-read inputs). ────────────── +function composeHealth({ system, grains, pipelines, ts }) { + return { ts, system: system || null, grains: grains || { stale: true, flows: {} }, pipelines: pipelines || {} }; +} + +module.exports = { + readProcStat, cpuPercent, readMem, readLoad, cpuCount, + classifyGrains, readGrainsSnapshot, shapePipelines, composeHealth, +}; diff --git a/backend/local-server.js b/backend/local-server.js index 5dea2c4..1c786b4 100644 --- a/backend/local-server.js +++ b/backend/local-server.js @@ -137,6 +137,70 @@ app.get('/api/mxl/ingest/qr/:slot.png', (req, res) => { }); }); +// ── Health Skin (read-only) ────────────────────────────────────────────────── +// A SEPARATE page from the control Skin: System (CPU/mem/load), Grain flow (bps + +// unique-fps per flow, catching repeat-wedges), per-source fps and pipeline-writer +// state. Nothing here mutates the facility — the Core stays lean and on-air-focused. +// +// The grain measurement is already done by tools/grain_probe.py (writes +// /grains.json every 3s, with its own load guards). We only read that file +// + /proc + the three pipeline/status endpoints, and cache the aggregate for a +// couple seconds so N open Health tabs collapse to one scrape. +const health = require('./health-info'); +const GRAINS_PATH = process.env.MXL_GRAINS_PATH || path.join(THUMBS_DIR, 'grains.json'); +const HEALTH_RATE = parseInt(process.env.MXL_GRAIN_RATE || '30', 10); +const HEALTH_TTL_MS = parseInt(process.env.MXL_HEALTH_TTL_MS || '2000', 10); +// Resolve the MXL control-API base the same way mxl-routes does, so the pipeline +// status probes hit the same VM the cuts do. +const HEALTH_MXL_VM = process.env.MXL_VM_URL + || (process.env.MXL_VM_FROM_MANIFEST === '1' && facility && facility.network && `http://${facility.network.mxl_vm}`) + || 'http://127.0.0.1'; + +function fetchJson(port, apiPath, timeoutMs = 3000) { + return fetch(`${HEALTH_MXL_VM}:${port}${apiPath}`, { signal: AbortSignal.timeout(timeoutMs) }) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null); // unreachable port -> null; shapePipelines marks it so +} + +let _prevCpu = health.readProcStat(); +let _healthCache = { at: 0, payload: null }; + +async function buildHealth() { + const [selector, keyer, encoder] = await Promise.all([ + fetchJson(9604, '/pipeline/status'), + fetchJson(9605, '/pipeline/status'), + fetchJson(9601, '/pipeline/status'), + ]); + const curCpu = health.readProcStat(); + const system = { + cpu_pct: health.cpuPercent(_prevCpu, curCpu), + cpus: health.cpuCount(), + mem: health.readMem(), + load: health.readLoad(), + }; + _prevCpu = curCpu; + const grains = health.classifyGrains( + health.readGrainsSnapshot(GRAINS_PATH), { expectedRate: HEALTH_RATE }); + const pipelines = health.shapePipelines({ selector, keyer, encoder }); + return health.composeHealth({ ts: Date.now() / 1000, system, grains, pipelines }); +} + +app.get('/api/mxl/health', async (req, res) => { + try { + const now = Date.now(); + if (!_healthCache.payload || now - _healthCache.at > HEALTH_TTL_MS) { + _healthCache = { at: now, payload: await buildHealth() }; + } + res.set('Cache-Control', 'no-store'); + res.json(_healthCache.payload); + } catch (e) { + res.status(500).json({ error: String((e && e.message) || e) }); + } +}); + +// The Health Skin page (read-only). Separate URL from the control Skin. +app.get('/health', (req, res) => res.sendFile(path.join(WEB_DIR, 'health.html'))); + // Serve the operator UI + its static assets. app.get('/', (req, res) => res.sendFile(path.join(WEB_DIR, 'local.html'))); app.use(express.static(WEB_DIR)); diff --git a/docs/HEALTH-SKIN.md b/docs/HEALTH-SKIN.md new file mode 100644 index 0000000..3d3074e --- /dev/null +++ b/docs/HEALTH-SKIN.md @@ -0,0 +1,60 @@ +# Health Skin — read-only facility monitor + +A second **Skin** over the same Core: a read-only page that shows what the switcher +is doing under the hood — grain flow, system load, and pipeline-writer state. It is +deliberately **separate from the control Skin** (`web/local.html`). A TD cutting a +show should see sources and a cut button, not grain internals; an engineer watching +health should not be able to touch the program bus. Core vs Skin, applied twice. + +## What it shows + +- **System** — CPU %, memory, 1/5/15-min load, CPU count, and the live WebRTC + viewer count (reported for free by the encoder). +- **Grain flow** — per flow: grains/s **and unique-frames/s**. The unique-fps column + is the one that matters: a flow can tick 30 grains/s while the *picture is frozen* + (a repeat-wedge — head advances, frame doesn't). When that happens unique-fps + collapses and the row goes amber (`frozen`). `dead` = probe read nothing; `slow` = + rate below half; `ok` = both near rate. +- **Pipeline writers** — selector / keyer / encoder: running state, errors, active + input, format, key-on, viewers, bitrate. + +## Where the data comes from (and why it's cheap) + +The expensive part — measuring grain rate *and* unique frames — is already done by +`tools/grain_probe.py`, which runs long-lived readers (one per flow) and writes a +rolling-window snapshot to `/grains.json` every 3 s. It has hard load guards +(pauses above load 26, exits above 29), so the probe can never be the thing that tips +the box over. + +The Core does **not** spawn probes or `docker exec` anything. `GET /api/mxl/health`: + +1. reads `grains.json` (already on disk), +2. reads `/proc` for CPU/mem/load, +3. fetches the three `:/pipeline/status` endpoints (same VM the cuts use), + +then caches the aggregate for ~2 s (`MXL_HEALTH_TTL_MS`) so N open Health tabs +collapse to a single scrape. The live PGM/encode path is never touched. + +## Running it + +- Serve: the Core (`backend/local-server.js`) serves the page at **`/health`** and the + data at **`/api/mxl/health`** — no extra process. +- Requires the grain probe to be running for the Grain-flow section to populate + (`tools/grain_probe.py`, started by the facility bring-up / its runner). If the probe + isn't running, the page still shows System + Pipeline writers and marks grains stale. + +## Config + +| env | default | meaning | +|-----|---------|---------| +| `MXL_GRAINS_PATH` | `/grains.json` | where grain_probe writes its snapshot | +| `MXL_GRAIN_RATE` | `30` | expected grain rate (1080p30) — classifies ok/slow/frozen | +| `MXL_HEALTH_TTL_MS` | `2000` | server-side cache TTL so viewers share one scrape | + +## Files + +- `backend/health-info.js` — pure aggregation/classification (unit-tested, no network) +- `backend/local-server.js` — `GET /api/mxl/health` + `GET /health` (read-only) +- `web/health.html` — the page (air-gapped: self-hosted fonts, no external hosts) +- `tests/js/health-info.test.js` — 14 unit tests (frozen/dead/slow, system, pipelines) +- `tests/test_local_ui.py` — air-gap + read-only + separation-from-Core checks diff --git a/tests/js/health-info.test.js b/tests/js/health-info.test.js new file mode 100644 index 0000000..a8fe9c3 --- /dev/null +++ b/tests/js/health-info.test.js @@ -0,0 +1,119 @@ +// Unit tests for backend/health-info.js — the pure (no-network, no-express) health +// aggregation used by the read-only Health Skin. Run with `node --test`. +// +// The marquee case: a flow can tick grains at full rate while the picture is +// FROZEN (a repeat-wedge). classifyGrains must flag that as 'frozen' from the +// unique-fps collapse — the exact failure we hit live with the keyer. +const test = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); + +const h = require(path.join(__dirname, '..', '..', 'backend', 'health-info.js')); + +// ── classifyGrains ──────────────────────────────────────────────────────────── +test('grains: healthy flow -> ok', () => { + const now = 1000; + const snap = { ts: 999.5, flows: { cam: { bps: 30, ufps: 29.7 } } }; + const out = h.classifyGrains(snap, { expectedRate: 30, nowS: now }); + assert.equal(out.stale, false); + assert.equal(out.flows.cam.state, 'ok'); +}); + +test('grains: FROZEN flow (full rate, unique collapsed) -> frozen', () => { + // 30 grains/s but only ~3 unique frames/s = repeat-wedge. + const snap = { ts: 1000, flows: { guest1: { bps: 30, ufps: 3 } } }; + const out = h.classifyGrains(snap, { expectedRate: 30, nowS: 1000 }); + assert.equal(out.flows.guest1.state, 'frozen', + 'a flow ticking at rate with collapsed unique-fps must read as frozen'); +}); + +test('grains: dead flow (null) -> dead', () => { + const snap = { ts: 1000, flows: { playout: null } }; + const out = h.classifyGrains(snap, { expectedRate: 30, nowS: 1000 }); + assert.equal(out.flows.playout.state, 'dead'); + assert.equal(out.flows.playout.bps, 0); +}); + +test('grains: slow flow (bps below half rate) -> slow', () => { + const snap = { ts: 1000, flows: { cam2: { bps: 10, ufps: 10 } } }; + const out = h.classifyGrains(snap, { expectedRate: 30, nowS: 1000 }); + assert.equal(out.flows.cam2.state, 'slow'); +}); + +test('grains: stale snapshot flagged by age', () => { + const snap = { ts: 900, flows: { cam: { bps: 30, ufps: 30 } } }; + const out = h.classifyGrains(snap, { expectedRate: 30, nowS: 1000, staleAfterS: 12 }); + assert.equal(out.stale, true, 'a 100s-old snapshot is stale'); + assert.equal(out.age_s, 100); +}); + +test('grains: missing/garbage snapshot -> stale, empty', () => { + const out = h.classifyGrains(null, { nowS: 1000 }); + assert.equal(out.stale, true); + assert.deepEqual(out.flows, {}); +}); + +// ── system parsing ──────────────────────────────────────────────────────────── +test('cpuPercent: computes busy fraction from two /proc/stat samples', () => { + const prev = { total: 1000, idle: 800 }; + const cur = { total: 1100, idle: 850 }; // +100 total, +50 idle -> 50% busy + assert.equal(h.cpuPercent(prev, cur), 50); +}); + +test('cpuPercent: null when no delta / missing sample', () => { + assert.equal(h.cpuPercent(null, { total: 1, idle: 0 }), null); + assert.equal(h.cpuPercent({ total: 10, idle: 5 }, { total: 10, idle: 5 }), null); +}); + +test('readMem: parses MemTotal/MemAvailable into used %', () => { + const fake = () => 'MemTotal: 8000000 kB\nMemAvailable: 2000000 kB\nMemFree: 1000000 kB\n'; + const m = h.readMem(fake); + assert.equal(m.total_mb, Math.round(8000000 / 1024)); + assert.equal(m.used_pct, 75); // (8000000-2000000)/8000000 +}); + +test('readProcStat: aggregate cpu line -> total/idle', () => { + const fake = () => 'cpu 100 0 100 700 100 0 0 0\ncpu0 1 2 3 4\n'; + const s = h.readProcStat(fake); + // fields: user100 nice0 system100 idle700 iowait100 irq0 softirq0 steal0 + // idle = idle(700)+iowait(100) = 800; total = sum of all = 1000 + assert.equal(s.idle, 800); + assert.equal(s.total, 1000); +}); + +test('cpuCount: counts per-core lines', () => { + const fake = () => 'cpu 1 2\ncpu0 1\ncpu1 1\ncpu2 1\n'; + assert.equal(h.cpuCount(fake), 3); +}); + +// ── pipeline shaping ────────────────────────────────────────────────────────── +test('shapePipelines: normalizes selector/keyer/encoder; marks unreachable', () => { + const out = h.shapePipelines({ + selector: { running: true, active_input: 2, input_flow_uuids: ['a', 'b', 'c'], + format: { frame_width: 1920, frame_height: 1080, grain_rate: { numerator: 30, denominator: 1 } } }, + keyer: { running: true, key_on: false, mode: 'key' }, + encoder: null, // unreachable + }); + assert.equal(out.selector.active_input, 2); + assert.equal(out.selector.inputs, 3); + assert.deepEqual(out.selector.format, { w: 1920, h: 1080, rate: 30 }); + assert.equal(out.keyer.key_on, false); + assert.equal(out.encoder.unreachable, true); + assert.equal(out.encoder.running, false); +}); + +test('shapePipelines: encoder viewers + bitrate surfaced', () => { + const out = h.shapePipelines({ + encoder: { running: true, viewers: 4, encoder: { bitrate: 6000, tune: 4 } }, + }); + assert.equal(out.encoder.viewers, 4); + assert.equal(out.encoder.bitrate_kbps, 6000); +}); + +// ── compose ─────────────────────────────────────────────────────────────────── +test('composeHealth: assembles a stable top-level shape', () => { + const out = h.composeHealth({ ts: 123, system: { cpu_pct: 10 }, grains: { stale: false, flows: {} }, pipelines: {} }); + assert.equal(out.ts, 123); + assert.equal(out.system.cpu_pct, 10); + assert.ok('grains' in out && 'pipelines' in out); +}); diff --git a/tests/test_local_ui.py b/tests/test_local_ui.py index c674164..f603da6 100644 --- a/tests/test_local_ui.py +++ b/tests/test_local_ui.py @@ -8,8 +8,10 @@ REPO = Path(__file__).resolve().parent.parent HTML = REPO / "web" / "local.html" +HEALTH_HTML = REPO / "web" / "health.html" SERVER = REPO / "backend" / "local-server.js" ROUTES = REPO / "backend" / "mxl-routes.js" +HEALTH_INFO = REPO / "backend" / "health-info.js" PKG = REPO / "backend" / "package.json" FONTS = REPO / "web" / "fonts" @@ -110,3 +112,51 @@ def test_package_pins_express(): import json pkg = json.loads(PKG.read_text()) assert "express" in pkg.get("dependencies", {}), "express not pinned in package.json" + + +# ── Health Skin (read-only monitor, separate page from the control Skin) ────── + +def test_health_skin_exists_and_is_separate(): + """The grain/system monitor is its own page + its own read-only endpoint — it + must NOT leak control into the Core Skin (local.html drives only control + routes). Keeping health separate is the whole point of 'Core vs Skin'.""" + assert HEALTH_HTML.is_file(), "web/health.html missing" + assert HEALTH_INFO.is_file(), "backend/health-info.js missing" + # the control Skin must not start calling /api/mxl/health — health is its own page + assert "/api/mxl/health" not in HTML.read_text(), \ + "the control Skin (local.html) should not poll health — keep it separate" + + +def test_health_skin_is_self_contained(): + """Same air-gap promise as the control Skin: no external hosts, self-hosted + fonts only, no CDN.""" + html = HEALTH_HTML.read_text() + urls = re.findall(r"https?://[a-z0-9.\-]+", html, re.I) + external = [u for u in urls if not re.search(r"(127\.0\.0\.1|localhost|w3\.org)", u)] + assert not external, f"health.html references external hosts: {external}" + assert "cdn" not in html.lower(), "health.html pulls from a CDN" + woff2 = list(FONTS.glob("*.woff2")) + for f in woff2: + assert f.name in html, f"health.html doesn't @font-face {f.name}" + + +def test_health_endpoint_is_read_only(): + """The health endpoint + page must be GET-only — a monitor never mutates the + facility. No POST/PUT/DELETE handlers touch the health surface.""" + src = SERVER.read_text() + assert "app.get('/api/mxl/health'" in src, "local-server missing GET /api/mxl/health" + assert "app.get('/health'" in src, "local-server doesn't serve /health page" + # no mutating verb on the health paths + for verb in ("post", "put", "delete", "patch"): + assert f"app.{verb}('/api/mxl/health'" not in src, \ + f"health endpoint must not expose {verb.upper()}" + + +def test_health_reuses_grain_probe_snapshot(): + """Cost control: the expensive grain measurement is grain_probe.py's job. The + endpoint must READ its snapshot (grains.json), not spawn its own probes.""" + src = SERVER.read_text() + assert "grains.json" in src, "health endpoint doesn't read the grain-probe snapshot" + # must cache so N viewers collapse to one scrape + assert "HEALTH_TTL" in src or "_healthCache" in src, \ + "health endpoint has no server-side cache (N viewers would each scrape)" diff --git a/web/health.html b/web/health.html new file mode 100644 index 0000000..bcfbeca --- /dev/null +++ b/web/health.html @@ -0,0 +1,218 @@ + + + + + +MXL Health + + + +
+
+
MXL Healthread-only facility monitor
+
+ connecting… + ← Switcher +
+ +

System

+
+
CPU
—
+
Memory
—
+
Load (1m)
—
—
+
Viewers
—
WebRTC program
+
+ +

Grain flow

+
+ + + +
FlowStateGrains/sUnique fps
waiting for probe…
+
+ +

Pipeline writers

+
+
Selector
state—
+
Keyer
state—
+
Encoder
state—
+
+ +
+ Grain rate and unique-frames/s come from the long-lived grain probe: a flow can tick 30 grains/s while the + picture is frozen (a repeat-wedge) — when that happens unique fps collapses + and the row turns amber. This page never controls the facility; it only reads. +
+
+ + + + From add96a2fa1a215330c2129d701f81b41ad31eae9 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 01:11:11 +0000 Subject: [PATCH 02/37] feat(health): read live thumbs health.json (already-running probe); grains.json fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live HW verify on the $400 box showed tools/mxl_thumbs.py ALREADY writes health.json beside the thumbnails (system + per-slot {age,frozen} + viewers) — same frozen-reader detection as grain_probe, but already running. So the Health Skin reads THAT (zero new process, no second set of MXL readers), and falls back to grain_probe's grains.json for adopters who run it instead. - classifyThumbsHealth(): {age,frozen} -> ok/frozen/dead; folds in load/mem/cpus/viewers. - endpoint prefers health.json, uses the probe's host-true system numbers when Core runs off-box; UI renders either shape (Grains/s+Unique-fps OR Last-frame+Content-age). - staleAfterS=25 to match the thumbs health writer's ~15s cadence. Verified live (read-only, no box changes): /health -> 200, real payload — load 15.6, 24 cpus, mem 18%, pattern/playout/guest1/guest2 OK, guest3/layout DEAD, selector running active_input#3 1920x1080@30, encoder 6000kbps. (It even surfaced the live 60fps-restamp 'Out of range' selector error — the monitor earning its keep.) Tests: 55 node / 139 python, all green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- backend/health-info.js | 47 +++++++++++++++++++++++++++++++++++- backend/local-server.js | 29 ++++++++++++++++++---- tests/js/health-info.test.js | 42 ++++++++++++++++++++++++++++++++ tests/test_local_ui.py | 10 +++++--- web/health.html | 20 ++++++++++++--- 5 files changed, 135 insertions(+), 13 deletions(-) diff --git a/backend/health-info.js b/backend/health-info.js index ef86dd7..9dd1edb 100644 --- a/backend/health-info.js +++ b/backend/health-info.js @@ -113,6 +113,51 @@ function readGrainsSnapshot(grainsPath, readFile = fs.readFileSync) { catch { return null; } } +// ── Thumbs health.json (the probe that's ALREADY running on the box) ────────── +// tools/mxl_thumbs.py writes health.json beside the thumbnails — same cadence, no +// extra process — with per-slot {age, frozen} where: +// age = seconds since the LAST frame arrived (delivery stalled -> dead) +// frozen = seconds since the CONTENT last CHANGED (picture stuck -> frozen) +// This is the cheaper, already-live source for the Health Skin: it carries the SAME +// frozen-reader detection as grain_probe's unique-fps, plus system + viewers, so we +// don't run a second set of MXL readers. classifyGrains (grains.json / grain_probe) +// stays supported for adopters who run that probe instead — both normalize to the +// same shape the UI consumes. +// staleAfterS default is 25: tools/mxl_thumbs.py's health loop writes on a ~15s +// cadence (its slots[].age/frozen are themselves sub-2s fresh, but the summary file +// is rewritten every 15s), so a poll landing late in that cycle can legitimately see +// an ~18-20s-old file — only flag stale past 25s (probe actually dead). grain_probe's +// grains.json is 3s — callers using it can pass a tighter staleAfterS. +function classifyThumbsHealth(h, { nowS, staleAfterS = 25, frozenAfterS = 5, deadAfterS = 5 } = {}) { + if (!h || typeof h !== 'object' || !h.slots) { + return { stale: true, age_s: null, flows: {}, system: null }; + } + const now = nowS != null ? nowS : Date.now() / 1000; + const ageS = h.ts != null ? Math.max(0, now - h.ts) : null; + const stale = ageS == null ? true : ageS > staleAfterS; + const flows = {}; + for (const [name, v] of Object.entries(h.slots)) { + if (!v || v.age == null) { flows[name] = { state: 'dead', age: null, frozen: null }; continue; } + let state = 'ok'; + if (v.age > deadAfterS) state = 'dead'; // no new frames at all + else if (v.frozen != null && v.frozen > frozenAfterS) state = 'frozen'; // frames arrive, picture stuck + flows[name] = { state, age: v.age, frozen: v.frozen != null ? v.frozen : null }; + } + // System block, assembled from the same file (load/mem are host-true in the probe). + const system = { + load: (h.load1 != null) ? { load1: h.load1, load5: h.load5, load15: h.load15 } : null, + cpus: h.cores != null ? h.cores : null, + mem: (h.mem_total_mb != null && h.mem_avail_mb != null) ? { + total_mb: h.mem_total_mb, + used_mb: h.mem_total_mb - h.mem_avail_mb, + used_pct: Math.round(((h.mem_total_mb - h.mem_avail_mb) / h.mem_total_mb) * 100), + } : null, + viewers: (h.viewers && typeof h.viewers === 'object') ? (h.viewers.count != null ? h.viewers.count : null) + : (typeof h.viewers === 'number' ? h.viewers : null), + }; + return { stale, age_s: ageS != null ? Math.round(ageS * 10) / 10 : null, flows, system, procs: h.procs || null }; +} + // ── Pipeline writers: normalize the three pipeline/status payloads for the UI. ─ // Pure — the caller fetches the JSON (or passes nulls when a port is unreachable) // and we shape it. No network here, so it's unit-testable. @@ -156,5 +201,5 @@ function composeHealth({ system, grains, pipelines, ts }) { module.exports = { readProcStat, cpuPercent, readMem, readLoad, cpuCount, - classifyGrains, readGrainsSnapshot, shapePipelines, composeHealth, + classifyGrains, classifyThumbsHealth, readGrainsSnapshot, shapePipelines, composeHealth, }; diff --git a/backend/local-server.js b/backend/local-server.js index 1c786b4..bb22367 100644 --- a/backend/local-server.js +++ b/backend/local-server.js @@ -147,6 +147,13 @@ app.get('/api/mxl/ingest/qr/:slot.png', (req, res) => { // + /proc + the three pipeline/status endpoints, and cache the aggregate for a // couple seconds so N open Health tabs collapse to one scrape. const health = require('./health-info'); +// Two possible probe snapshots, in preference order: +// health.json — written by tools/mxl_thumbs.py, which is ALREADY running beside the +// multiview thumbnails (no extra process, no extra MXL readers). Carries per-slot +// {age, frozen} (same frozen-reader detection) + system + viewers. +// grains.json — written by tools/grain_probe.py (bps + unique-fps). Supported for +// adopters who run that probe; we fall back to it if health.json isn't present. +const THUMBS_HEALTH_PATH = process.env.MXL_THUMBS_HEALTH_PATH || path.join(THUMBS_DIR, 'health.json'); const GRAINS_PATH = process.env.MXL_GRAINS_PATH || path.join(THUMBS_DIR, 'grains.json'); const HEALTH_RATE = parseInt(process.env.MXL_GRAIN_RATE || '30', 10); const HEALTH_TTL_MS = parseInt(process.env.MXL_HEALTH_TTL_MS || '2000', 10); @@ -171,16 +178,28 @@ async function buildHealth() { fetchJson(9605, '/pipeline/status'), fetchJson(9601, '/pipeline/status'), ]); + // Prefer the already-running thumbs health.json; fall back to grain_probe's grains.json. + const thumbs = health.readGrainsSnapshot(THUMBS_HEALTH_PATH); + let grains, probeSystem = null; + if (thumbs && thumbs.slots) { + const t = health.classifyThumbsHealth(thumbs, {}); + grains = { stale: t.stale, age_s: t.age_s, flows: t.flows, source: 'thumbs' }; + probeSystem = t.system; // load/mem/viewers measured where the probe runs (the box) + } else { + grains = health.classifyGrains(health.readGrainsSnapshot(GRAINS_PATH), { expectedRate: HEALTH_RATE }); + grains.source = 'grains'; + } + // System: use the probe's host-true numbers when available (Core may run off-box); + // otherwise read local /proc. CPU% always comes from local /proc deltas. const curCpu = health.readProcStat(); const system = { cpu_pct: health.cpuPercent(_prevCpu, curCpu), - cpus: health.cpuCount(), - mem: health.readMem(), - load: health.readLoad(), + cpus: (probeSystem && probeSystem.cpus) || health.cpuCount(), + mem: (probeSystem && probeSystem.mem) || health.readMem(), + load: (probeSystem && probeSystem.load) || health.readLoad(), + viewers: probeSystem ? probeSystem.viewers : null, }; _prevCpu = curCpu; - const grains = health.classifyGrains( - health.readGrainsSnapshot(GRAINS_PATH), { expectedRate: HEALTH_RATE }); const pipelines = health.shapePipelines({ selector, keyer, encoder }); return health.composeHealth({ ts: Date.now() / 1000, system, grains, pipelines }); } diff --git a/tests/js/health-info.test.js b/tests/js/health-info.test.js index a8fe9c3..1d09b71 100644 --- a/tests/js/health-info.test.js +++ b/tests/js/health-info.test.js @@ -53,6 +53,48 @@ test('grains: missing/garbage snapshot -> stale, empty', () => { assert.deepEqual(out.flows, {}); }); +// ── classifyThumbsHealth (the probe already running on the box) ───────────────── +test('thumbs: live slot (fresh frames, content changing) -> ok', () => { + const h2 = { ts: 1000, slots: { pattern: { age: 1.2, frozen: 1.2 } }, load1: 2 }; + const out = h.classifyThumbsHealth(h2, { nowS: 1000 }); + assert.equal(out.flows.pattern.state, 'ok'); + assert.equal(out.stale, false); +}); + +test('thumbs: FROZEN slot (frames arrive but content stuck) -> frozen', () => { + // age low (frames keep arriving) but frozen high (picture hasn't changed). + const h2 = { ts: 1000, slots: { guest1: { age: 1.0, frozen: 11.0 } } }; + const out = h.classifyThumbsHealth(h2, { nowS: 1000, frozenAfterS: 5 }); + assert.equal(out.flows.guest1.state, 'frozen', + 'frames arriving but content unchanged for >5s must read as frozen'); +}); + +test('thumbs: dead slot (no frames at all / null) -> dead', () => { + const h2 = { ts: 1000, slots: { guest3: null, playout: { age: 9.0, frozen: 9.0 } } }; + const out = h.classifyThumbsHealth(h2, { nowS: 1000, deadAfterS: 5 }); + assert.equal(out.flows.guest3.state, 'dead'); + assert.equal(out.flows.playout.state, 'dead', 'no new frames for >5s -> dead'); +}); + +test('thumbs: pulls system (load/mem/cpus/viewers) from the same file', () => { + const h2 = { + ts: 1000, slots: {}, + load1: 12.5, load5: 12, load15: 11, + cores: 24, mem_total_mb: 16000, mem_avail_mb: 4000, viewers: 3, + }; + const out = h.classifyThumbsHealth(h2, { nowS: 1000 }); + assert.equal(out.system.load.load1, 12.5); + assert.equal(out.system.cpus, 24); + assert.equal(out.system.mem.used_pct, 75); + assert.equal(out.system.viewers, 3); +}); + +test('thumbs: missing/garbage -> stale, empty', () => { + const out = h.classifyThumbsHealth(null, { nowS: 1000 }); + assert.equal(out.stale, true); + assert.deepEqual(out.flows, {}); +}); + // ── system parsing ──────────────────────────────────────────────────────────── test('cpuPercent: computes busy fraction from two /proc/stat samples', () => { const prev = { total: 1000, idle: 800 }; diff --git a/tests/test_local_ui.py b/tests/test_local_ui.py index f603da6..cd34234 100644 --- a/tests/test_local_ui.py +++ b/tests/test_local_ui.py @@ -152,11 +152,13 @@ def test_health_endpoint_is_read_only(): f"health endpoint must not expose {verb.upper()}" -def test_health_reuses_grain_probe_snapshot(): - """Cost control: the expensive grain measurement is grain_probe.py's job. The - endpoint must READ its snapshot (grains.json), not spawn its own probes.""" +def test_health_reuses_existing_probe_snapshot(): + """Cost control: the endpoint must READ an existing probe's snapshot, never spawn + its own MXL readers. It prefers the already-running thumbs health.json and falls + back to grain_probe's grains.json — either way, no new process.""" src = SERVER.read_text() - assert "grains.json" in src, "health endpoint doesn't read the grain-probe snapshot" + assert "health.json" in src, "health endpoint doesn't read the thumbs health snapshot" + assert "grains.json" in src, "health endpoint doesn't fall back to the grain-probe snapshot" # must cache so N viewers collapse to one scrape assert "HEALTH_TTL" in src or "_healthCache" in src, \ "health endpoint has no server-side cache (N viewers would each scrape)" diff --git a/web/health.html b/web/health.html index bcfbeca..e8ca8ff 100644 --- a/web/health.html +++ b/web/health.html @@ -107,7 +107,7 @@

System

Grain flow

- +
FlowStateGrains/sUnique fps
FlowStateGrains/sUnique fps
waiting for probe…
@@ -148,6 +148,12 @@

Pipeline writers

var cores=s.cpus?(' / '+s.cpus+' cores'):''; $('loadSub').textContent=s.load.load5.toFixed(1)+' · '+s.load.load15.toFixed(1)+cores; } + if(s.cpu_pct==null && s.load && s.cpus){ + // CPU% unavailable (Core running off-box) — approximate from load/cores. + var approx=Math.min(100,Math.round(s.load.load1/s.cpus*100)); + $('cpu').innerHTML=approx+'% ~load'; setBar($('cpuBar'),approx); + } + if(s.viewers!=null) $('viewers').textContent=s.viewers; } function renderGrains(g){ @@ -156,14 +162,22 @@

Pipeline writers

else { stale.textContent=''; stale.className='state'; } var tbody=$('grainRows'), flows=(g&&g.flows)||{}; var names=Object.keys(flows); + // Two probe shapes: grain_probe -> {bps, ufps}; thumbs health -> {age, frozen}. + var isThumbs=(g&&g.source==='thumbs'); + var head=$('grainHead'); + if(isThumbs) head.innerHTML='FlowStateLast frameContent age'; + else head.innerHTML='FlowStateGrains/sUnique fps'; if(!names.length){ tbody.innerHTML='no flows reported'; return; } tbody.innerHTML=names.map(function(n){ var f=flows[n], st=f.state||'dead'; + var c1,c2; + if(isThumbs){ c1=(f.age!=null?f.age+'s':'—'); c2=(f.frozen!=null?f.frozen+'s':'—'); } + else { c1=(f.bps!=null?f.bps:'—'); c2=(f.ufps!=null?f.ufps:'—'); } return ''+ ''+esc(n)+''+ ''+st+''+ - ''+(f.bps!=null?f.bps:'—')+''+ - ''+(f.ufps!=null?f.ufps:'—')+''+ + ''+c1+''+ + ''+c2+''+ ''; }).join(''); } From 724af0556d04cd11e62bc78fab9e622e81a9e5cd Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 01:11:48 +0000 Subject: [PATCH 03/37] =?UTF-8?q?docs(health):=20reflect=20HW=20finding=20?= =?UTF-8?q?=E2=80=94=20reads=20already-running=20thumbs=20health.json,=20g?= =?UTF-8?q?rain=5Fprobe=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/HEALTH-SKIN.md | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/HEALTH-SKIN.md b/docs/HEALTH-SKIN.md index 3d3074e..bc8bfe0 100644 --- a/docs/HEALTH-SKIN.md +++ b/docs/HEALTH-SKIN.md @@ -20,28 +20,39 @@ health should not be able to touch the program bus. Core vs Skin, applied twice. ## Where the data comes from (and why it's cheap) -The expensive part — measuring grain rate *and* unique frames — is already done by -`tools/grain_probe.py`, which runs long-lived readers (one per flow) and writes a -rolling-window snapshot to `/grains.json` every 3 s. It has hard load guards -(pauses above load 26, exits above 29), so the probe can never be the thing that tips -the box over. +The grain/frozen measurement is **already being produced** on a running facility: +`tools/mxl_thumbs.py` (the multiview thumbnail generator) also writes `health.json` +beside the thumbnails, carrying per-slot `{age, frozen}` — `age` = seconds since the +last frame, `frozen` = seconds since the content last *changed* (its own repeat-wedge +detector). Plus system load/mem and viewers. Because the thumbnail probe is already +running, the Health Skin adds **no new process and no second set of MXL readers**. -The Core does **not** spawn probes or `docker exec` anything. `GET /api/mxl/health`: +`GET /api/mxl/health`: -1. reads `grains.json` (already on disk), -2. reads `/proc` for CPU/mem/load, +1. reads `health.json` (already on disk; falls back to `grains.json` if you run + `tools/grain_probe.py` instead — that probe adds unique-fps and has hard load + guards: pauses above load 26, exits above 29), +2. uses the probe's host-true load/mem/viewers (so it's correct even when the Core + runs off-box) and local `/proc` for CPU %, 3. fetches the three `:/pipeline/status` endpoints (same VM the cuts use), then caches the aggregate for ~2 s (`MXL_HEALTH_TTL_MS`) so N open Health tabs -collapse to a single scrape. The live PGM/encode path is never touched. +collapse to a single scrape. The Core never spawns a probe or `docker exec`s anything, +and the live PGM/encode path is never touched. + +> Note: `mxl_thumbs.py`'s `health.json` is rewritten on a ~15 s cadence (the per-slot +> age/frozen values inside it are sub-2 s fresh). For a tighter live refresh, run +> `tools/grain_probe.py` (3 s, adds unique-fps) — the endpoint picks up `grains.json` +> automatically. ## Running it - Serve: the Core (`backend/local-server.js`) serves the page at **`/health`** and the data at **`/api/mxl/health`** — no extra process. -- Requires the grain probe to be running for the Grain-flow section to populate - (`tools/grain_probe.py`, started by the facility bring-up / its runner). If the probe - isn't running, the page still shows System + Pipeline writers and marks grains stale. +- The Grain-flow section populates from whichever probe snapshot exists: + `health.json` (written by the already-running `tools/mxl_thumbs.py`) is used first; + `grains.json` (from `tools/grain_probe.py`) is the fallback. If neither is present the + page still shows System + Pipeline writers and marks grains stale. ## Config From b508360851ac4f5dfd05cd81551f7a093d670774 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 01:17:02 +0000 Subject: [PATCH 04/37] feat(health): read probe snapshot over MXL_THUMBS_ORIGIN (proxy mode), not just file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a box where the MXL domain is only mounted inside containers, the thumbnails (and health.json/grains.json beside them) are exposed via a small http server (MXL_THUMBS_ORIGIN, e.g. :8086/thumbs) — same as the /api/mxl/thumbs route already does. The health endpoint now fetches the snapshot over that origin when set, falling back to a local file read otherwise. Keeps Core off the Docker socket + portable. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- backend/local-server.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/backend/local-server.js b/backend/local-server.js index bb22367..004b239 100644 --- a/backend/local-server.js +++ b/backend/local-server.js @@ -157,6 +157,24 @@ const THUMBS_HEALTH_PATH = process.env.MXL_THUMBS_HEALTH_PATH || path.join(THUMB const GRAINS_PATH = process.env.MXL_GRAINS_PATH || path.join(THUMBS_DIR, 'grains.json'); const HEALTH_RATE = parseInt(process.env.MXL_GRAIN_RATE || '30', 10); const HEALTH_TTL_MS = parseInt(process.env.MXL_HEALTH_TTL_MS || '2000', 10); +// The probe snapshots live wherever the thumbnails do. In file mode we read +// /*.json; in proxy mode (MXL_THUMBS_ORIGIN set — e.g. the domain is only +// mounted in a container and a tiny http server exposes it) we fetch the same JSON over +// HTTP, exactly like the /api/mxl/thumbs route. This keeps the Core off the Docker +// socket and portable to either layout. +const THUMBS_SNAP_ORIGIN = THUMBS_ORIGIN; // e.g. http://127.0.0.1:8086/thumbs + +async function readSnapshot(fileName, filePath) { + if (THUMBS_SNAP_ORIGIN) { + try { + const base = THUMBS_SNAP_ORIGIN.replace(/\/$/, ''); + const r = await fetch(`${base}/${fileName}`, { signal: AbortSignal.timeout(3000) }); + return r.ok ? await r.json() : null; + } catch { return null; } + } + return health.readGrainsSnapshot(filePath); // file mode (sync read, cheap) +} + // Resolve the MXL control-API base the same way mxl-routes does, so the pipeline // status probes hit the same VM the cuts do. const HEALTH_MXL_VM = process.env.MXL_VM_URL @@ -179,14 +197,15 @@ async function buildHealth() { fetchJson(9601, '/pipeline/status'), ]); // Prefer the already-running thumbs health.json; fall back to grain_probe's grains.json. - const thumbs = health.readGrainsSnapshot(THUMBS_HEALTH_PATH); + const thumbs = await readSnapshot('health.json', THUMBS_HEALTH_PATH); let grains, probeSystem = null; if (thumbs && thumbs.slots) { const t = health.classifyThumbsHealth(thumbs, {}); grains = { stale: t.stale, age_s: t.age_s, flows: t.flows, source: 'thumbs' }; probeSystem = t.system; // load/mem/viewers measured where the probe runs (the box) } else { - grains = health.classifyGrains(health.readGrainsSnapshot(GRAINS_PATH), { expectedRate: HEALTH_RATE }); + const gr = await readSnapshot('grains.json', GRAINS_PATH); + grains = health.classifyGrains(gr, { expectedRate: HEALTH_RATE }); grains.source = 'grains'; } // System: use the probe's host-true numbers when available (Core may run off-box); From 8ef61ff5c1414cde3de97382f47468ccfa1550b9 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 01:20:17 +0000 Subject: [PATCH 05/37] feat(health): CORS allow-origin on /api/mxl/health so the public site can embed live numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only public telemetry (GET, no secrets, no control) — mxlswitcher.com fetches it to show the grain explainer's live data. Default '*', override via MXL_HEALTH_CORS. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- backend/local-server.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/local-server.js b/backend/local-server.js index 004b239..f591a4d 100644 --- a/backend/local-server.js +++ b/backend/local-server.js @@ -230,6 +230,9 @@ app.get('/api/mxl/health', async (req, res) => { _healthCache = { at: now, payload: await buildHealth() }; } res.set('Cache-Control', 'no-store'); + // Read-only public telemetry — allow cross-origin reads so the public site + // (mxlswitcher.com) can embed the live numbers. GET-only, no secrets, no control. + res.set('Access-Control-Allow-Origin', process.env.MXL_HEALTH_CORS || '*'); res.json(_healthCache.payload); } catch (e) { res.status(500).json({ error: String((e && e.message) || e) }); From 97598aa2cf760faab4a3deb31d208286063b37f7 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 01:21:51 +0000 Subject: [PATCH 06/37] site: 'What's a grain?' layman explainer with LIVE data from the $400 box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain-English page (grain=one frame, flow=one source, shared memory=one copy everyone reads) that embeds the switcher's real /api/mxl/health — live source states + 'what the $400 box is doing right now' (load/CPU/mem). Linked first in the front-door learn cards. Pulls from the public mxl-skin.cochran.cloud health endpoint (CORS-enabled, read-only). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- docs/index.html | 1 + docs/what-are-grains.html | 124 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 docs/what-are-grains.html diff --git a/docs/index.html b/docs/index.html index 975a3ce..5f90762 100644 --- a/docs/index.html +++ b/docs/index.html @@ -289,6 +289,7 @@

From fresh Ubuntu VM to a cuttable MXL production in about ten minutes.

The point of this lab isn't the demo — it's the knowledge. If you want to understand MXL and where production is heading, start with these. All measured, all in the open.

diff --git a/web/welcome.html b/web/welcome.html new file mode 100644 index 0000000..eaa2a04 --- /dev/null +++ b/web/welcome.html @@ -0,0 +1,104 @@ + + + + + +MXL — a live switcher on one $400 box + + + +
+
Open-source · MXL / Dynamic Media Facility
+

A broadcast control room, as software

+

One $400 computer
is running a live show.

+

Real cameras, phone guests, playout, switching and streaming — all in shared memory on commodity hardware. Jump in: put your own camera on air, watch the program, or watch the machine work.

+

checking the facility…

+ + + +

+ New to this? What's a "grain"? → +  ·  Built on the EBU/AMWA Media eXchange Layer. +

+
+ + + + From 6ff362b9f653194efded8502606bcab99e2a5dd0 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 02:21:11 +0000 Subject: [PATCH 12/37] site: add 'Try it LIVE' hero button on mxlswitcher.com -> live /welcome demo (put your phone on air) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- docs/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.html b/docs/index.html index 5f90762..aabfd53 100644 --- a/docs/index.html +++ b/docs/index.html @@ -87,7 +87,7 @@

What if the switcher
was shared memory?

A working exploration of a live production control room built around the Media eXchange Layer: real cameras, remote guests, playout, layouts, graphics, audio, multiview, recording and standard control — assembled as software on general-purpose cloud VMs.

From 5923faa105a18452a2349b982da26d5c20158215 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 04:40:02 +0000 Subject: [PATCH 13/37] feat(join): multiview return grid + auto-assign a FREE slot (fix 'my feed didn't appear') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why the phone didn't show: /join sent everyone to guest3 (:8895), but the PTZ already held that listener (SRT listen mode = one publisher per port) → the phone was rejected. - /api/mxl/ingest now annotates each slot free/occupied (from the probe snapshot) and returns suggested_slot = first FREE slot. /join uses it, so two scanners don't collide. - /join now shows a live MULTIVIEW return grid (source thumbnails, near-zero cost — the switcher already generates them): the visitor's OWN tile is ringed cyan + 'YOU' and the status flips to 'You're LIVE 🎥' the moment their source delivers frames. Plus the PGM preview below. This is the 'can we see you?' feedback — answers the design question: show the MULTIVIEW as the return (instant self-confirmation), PGM as 'what's on air'. - A true encoded multiview (mv_encode.py exists) stays the TD's pop-out option — overkill for /join; the thumbnail grid scales to unlimited join viewers. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- backend/local-server.js | 22 ++++++++- web/join.html | 103 +++++++++++++++++++++++++++++++++------- 2 files changed, 105 insertions(+), 20 deletions(-) diff --git a/backend/local-server.js b/backend/local-server.js index 4df7cfa..3a7b9fa 100644 --- a/backend/local-server.js +++ b/backend/local-server.js @@ -116,8 +116,26 @@ const INGEST_CFG = ingestConfig({ // The set of valid guest slots for the QR route — derived from the config, not hardcoded. const INGEST_SLOTS = new Set((INGEST_CFG.guests || []).map((g) => g.slot)); -app.get('/api/mxl/ingest', (req, res) => { - res.json(ingestInfo(INGEST_CFG)); +app.get('/api/mxl/ingest', async (req, res) => { + const info = ingestInfo(INGEST_CFG); + // Annotate each guest slot as free/occupied so the /join page can auto-assign the + // first FREE slot (two visitors must not be sent to the same busy listener — SRT + // listen mode is one-publisher-per-port, so a taken slot rejects the next phone). + // We derive "occupied" from the probe snapshot (a flow advancing = someone's on it). + try { + const snap = await readSnapshot('health.json', THUMBS_HEALTH_PATH); + const flows = (snap && snap.slots) || {}; + info.guests = info.guests.map((g) => { + const f = flows[g.slot]; + // occupied = the slot's source is delivering fresh frames (age present + small) + const occupied = !!(f && f.age != null && f.age < 5); + return { ...g, occupied, free: !occupied }; + }); + const firstFree = info.guests.find((g) => g.free); + info.suggested_slot = firstFree ? firstFree.slot : null; + } catch { /* no snapshot — leave slots unannotated */ } + res.set('Access-Control-Allow-Origin', process.env.MXL_HEALTH_CORS || '*'); + res.json(info); }); // Server-rendered QR PNG for a guest slot. Uses python3 + segno (pure-python, diff --git a/web/join.html b/web/join.html index 39ea24c..7eb899b 100644 --- a/web/join.html +++ b/web/join.html @@ -69,6 +69,19 @@ .status{margin-top:10px;font-size:13px;color:var(--dim)} .status b{color:var(--grn)} .status.waiting b{color:var(--amber)} + /* multiview return grid */ + .mv{display:grid;grid-template-columns:repeat(3,1fr);gap:8px} + @media(max-width:520px){.mv{grid-template-columns:repeat(2,1fr)}} + .mvtile{position:relative;background:#000;border:1px solid var(--line);border-radius:9px;overflow:hidden;aspect-ratio:16/9} + .mvtile img{width:100%;height:100%;object-fit:cover;display:block} + .mvtile .lbl{position:absolute;bottom:0;left:0;right:0;font-size:10px;padding:3px 6px;background:linear-gradient(transparent,rgba(0,0,0,.8));color:var(--ink);font-weight:600;letter-spacing:.5px} + .mvtile .dot{position:absolute;top:6px;right:6px;width:8px;height:8px;border-radius:50%;background:var(--faint)} + .mvtile.live .dot{background:var(--grn);box-shadow:0 0 8px rgba(35,217,124,.6)} + .mvtile.dead{opacity:.4} + .mvtile.you{border-color:var(--cyan);box-shadow:0 0 0 2px var(--cyan),0 0 18px rgba(53,224,255,.5)} + .mvtile.you .lbl{background:var(--cyan);color:#05070c} + .mvtile .me{position:absolute;top:6px;left:6px;font-size:9px;background:var(--cyan);color:#05070c;font-weight:700;padding:1px 6px;border-radius:4px;letter-spacing:.5px;display:none} + .mvtile.you .me{display:block} .footer{margin-top:30px;color:var(--faint);font-size:12px;border-top:1px solid var(--line);padding-top:16px} @@ -126,12 +139,17 @@

Put your phone's camera
into a live broadcast.

-

Program — you'll appear here

+

Can we see you? — your tile lights up when you're live

+
+
Waiting for a source… go live in Larix and watch your tile appear below.
+
+ +
+

Program output — what's on air

● LIVE
-
Waiting for a source… once you go live, ask the director to cut to you — or watch the sources count rise.
@@ -144,46 +162,95 @@

Program — you'll appear here

From 5053bad291b256f7cbd8ce57157c8f787a79889e Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 04:55:23 +0000 Subject: [PATCH 14/37] feat(join): multiview grid shows all guest slots dynamically (guest4-6 + future) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The return-feed grid now orders known sources then appends any others the health reports, and rebuilds when the source set changes — so newly-added guest slots (guest4, guest5, …) appear automatically without a code change. Pairs with the box now running 6 guest ingest slots for public joiners. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- web/join.html | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/web/join.html b/web/join.html index 7eb899b..9b12739 100644 --- a/web/join.html +++ b/web/join.html @@ -200,19 +200,23 @@

Program output '+ ''+esc(pretty(n))+''+ 'YOU'+ ''+esc(pretty(n))+''; }).join(''); - builtGrid=true; + builtGrid=true; gridKeys=names.slice().sort().join(','); } function refreshThumbs(){ Array.prototype.forEach.call(document.querySelectorAll('.mvtile img'),function(img){ @@ -224,7 +228,8 @@

Program output Date: Fri, 9 Oct 2026 05:12:25 +0000 Subject: [PATCH 15/37] feat(skin): add Health + Join links to the Core top nav (read-only, for everyone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health is read-only telemetry useful to anyone watching the rack — surface it from the control Skin's header, alongside a Join link for quick QR hand-off. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- web/local.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/local.html b/web/local.html index 4dc0ba6..16f49e4 100644 --- a/web/local.html +++ b/web/local.html @@ -41,6 +41,8 @@ font-weight:500;letter-spacing:.5px;color:var(--dim);border:1px solid var(--line); border-radius:6px;padding:5px 9px;text-transform:uppercase} .chip .dot{width:7px;height:7px;border-radius:50%;background:var(--faint)} + a.navlink{text-decoration:none;color:var(--dim);transition:color .15s,border-color .15s} + a.navlink:hover{color:var(--acc);border-color:var(--acc)} .chip.ok{color:var(--ink)} .chip.ok .dot{background:var(--grn);box-shadow:0 0 7px var(--grn-glow)} .chip.bad .dot{background:var(--red);box-shadow:0 0 7px var(--red-glow)} .chip b{color:var(--ink);font-weight:600} @@ -156,6 +158,8 @@
MXL Switcher
+ Health ↗ + Join ↗
connecting…
PGM —
From bfae7d0b2bc77744a8fcba2444b53271af5d9fba Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 05:21:31 +0000 Subject: [PATCH 16/37] =?UTF-8?q?feat(reliability):=20guest-leg-watchdog?= =?UTF-8?q?=20=E2=80=94=20heal=20wedged=20SRT=20ingest=20legs=20so=20phone?= =?UTF-8?q?=20joins=20always=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silent demo-killer (HW Oct 9): a guest ingest chain is fanout(public SRT) + video-core + audio-core; if EITHER internal leg listener dies, the fanout rejects the whole incoming connection and the phone gets 'Input/output error' with no clue why. Found guest1 AND guest2 audio legs dead, both blocking joins. tools/guest-leg-watchdog.sh polls each guest's 3 ports every 20s; a partial chain (1-2 of 3 up) = a wedged leg → restart the chain in dependency order (audio→core→fanout) with a per-chain cooldown. A fully-down chain = not provisioned → skipped (guest4-6 optional). +6 tests. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- tests/test_guest_leg_watchdog.py | 62 ++++++++++++++++++++++++++++ tools/guest-leg-watchdog.sh | 71 ++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 tests/test_guest_leg_watchdog.py create mode 100644 tools/guest-leg-watchdog.sh diff --git a/tests/test_guest_leg_watchdog.py b/tests/test_guest_leg_watchdog.py new file mode 100644 index 0000000..275554e --- /dev/null +++ b/tests/test_guest_leg_watchdog.py @@ -0,0 +1,62 @@ +"""guest-leg-watchdog.sh invariants (text-scanned — it's bash, no deps). + +The watchdog heals the SRT-leg wedge that silently blocks phone joins: a guest ingest +chain is fanout(public) + core(video leg) + audio(audio leg), and if EITHER internal leg +is down the fanout rejects the whole incoming connection. These checks guard the safety +properties that keep the watchdog from thrashing or healing the wrong thing. +""" +import re +from pathlib import Path + +WD = Path(__file__).resolve().parent.parent / "tools" / "guest-leg-watchdog.sh" + + +def _src(): + assert WD.is_file(), "tools/guest-leg-watchdog.sh missing" + return WD.read_text() + + +def test_exists_and_bash_shebang(): + s = _src() + assert s.startswith("#!/usr/bin/env bash") or s.startswith("#!/bin/bash") + + +def test_restarts_chain_in_dependency_order(): + """The cores (leg listeners) must come up before the fanout tries to write to them, + or the fanout immediately re-wedges. Order must be audio, core, fanout.""" + s = _src() + m = re.search(r'docker restart\s+"\$\{name\}-audio"\s+"\$\{name\}"\s+"\$\{name\}-fanout"', s) + assert m, "heal must restart ${name}-audio ${name} ${name}-fanout in that order" + + +def test_has_cooldown_to_avoid_thrash(): + """A flapping chain must not be restarted every poll — a per-chain cooldown guards it.""" + s = _src() + assert "COOLDOWN_SECONDS" in s, "no cooldown guard" + assert "last_heal" in s, "cooldown must track last heal time per chain" + + +def test_skips_absent_guests(): + """guest4-6 may not exist on a smaller deployment — the watchdog must skip a guest + whose containers aren't present rather than trying to restart nothing.""" + s = _src() + assert "docker ps" in s and "grep -qx" in s, "must check the container exists before healing" + assert "continue" in s, "absent guest must be skipped (continue)" + + +def test_heals_only_partial_chains_not_fully_down(): + """0 ports up = not provisioned / still starting (skip); 1-2 up = a leg wedged (heal); + 3 up = healthy. Healing a fully-down chain would thrash a guest that's just absent.""" + s = _src() + assert re.search(r'"\$up"\s*-eq\s*1', s) and re.search(r'"\$up"\s*-eq\s*2', s), \ + "must heal when exactly 1 or 2 of 3 ports are up" + assert not re.search(r'"\$up"\s*-eq\s*0.*heal', s), "must NOT heal a fully-down (absent) chain" + + +def test_watches_all_six_guest_chains_by_default(): + """The default port map must cover guest1-6 with their real (non-contiguous) ports — + guest3 on :8895, not the computed :8892.""" + s = _src() + for spec in ("guest1:8890", "guest2:8891", "guest3:8895", + "guest4:8896", "guest5:8897", "guest6:8898"): + assert spec in s, f"default GUEST_PORTS missing {spec}" diff --git a/tools/guest-leg-watchdog.sh b/tools/guest-leg-watchdog.sh new file mode 100644 index 0000000..ffbabd1 --- /dev/null +++ b/tools/guest-leg-watchdog.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# guest-leg-watchdog — keeps each guest's SRT ingest chain healthy so a contributor +# (phone/Larix) can always connect. +# +# THE WEDGE IT HEALS (HW Oct 9): each guest ingest is a 3-container chain — +# -fanout (public SRT listener :PUB) → splits A/V to two internal legs +# (video core, listens :VLEG) → writes the guest VIDEO flow +# -audio (audio core, listens :ALEG) → writes the guest AUDIO flow +# guest_av_listen.sh writes to BOTH legs; if EITHER leg's listener is down, the +# fanout's SRT write fails ("Socket is broken or closed, Trying to reconnect") and it +# REJECTS the whole incoming connection — so a phone (and even a clean local caller) +# gets "Input/output error" and never appears. Seen live: guest2 audio leg :9091 dead, +# guest1 audio leg :9090 dead — both silently blocking joins. +# +# This watchdog polls every guest's three ports; if a chain is incomplete (public up but +# a leg missing, or vice versa) it restarts that chain in order (audio → core → fanout) +# and logs it. A chain with ALL ports down is treated as "not provisioned" and skipped +# (guest4-6 may legitimately not exist on a smaller deployment). +# +# Config (env-overridable; defaults match the box): +# GUEST_PORTS per-guest "name:pub:vleg:aleg" specs, comma-separated +# POLL_SECONDS default 20 +# COOLDOWN_SECONDS min seconds between restarts of the SAME chain (default 60) +set -u + +POLL_SECONDS=${POLL_SECONDS:-20} +COOLDOWN_SECONDS=${COOLDOWN_SECONDS:-60} + +# name:public:video-leg:audio-leg (the box's scheme; guest1/2 contiguous, 3-6 bolt-ons) +GUEST_PORTS=${GUEST_PORTS:-"guest1:8890:8990:9090,guest2:8891:8991:9091,guest3:8895:8995:9095,guest4:8896:8996:9096,guest5:8897:8997:9097,guest6:8898:8998:9098"} + +declare -A last_heal + +bound() { ss -ulnp 2>/dev/null | grep -q ":$1 "; } # is a UDP port listening? + +heal_chain() { + local name=$1 + local now; now=$(date +%s) + local last=${last_heal[$name]:-0} + if [ $((now - last)) -lt "$COOLDOWN_SECONDS" ]; then + logger -t guest-leg-watchdog "skip $name — in cooldown ($((now - last))s < ${COOLDOWN_SECONDS}s)" + return + fi + last_heal[$name]=$now + logger -t guest-leg-watchdog "HEALING $name chain (a leg was down) — restart audio→core→fanout" + # order matters: the cores (leg listeners) must be up before the fanout tries to write + docker restart "${name}-audio" "${name}" "${name}-fanout" >/dev/null 2>&1 +} + +logger -t guest-leg-watchdog "started — watching: $GUEST_PORTS (poll ${POLL_SECONDS}s)" +while :; do + IFS=',' read -ra specs <<< "$GUEST_PORTS" + for spec in "${specs[@]}"; do + IFS=':' read -r name pub vleg aleg <<< "$spec" + # skip guests that don't exist as containers on this deployment + docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "${name}-fanout" || continue + + up=0 + bound "$pub" && up=$((up+1)) + bound "$vleg" && up=$((up+1)) + bound "$aleg" && up=$((up+1)) + + # 0 up = container exists but nothing bound (starting up) → give it a cycle, don't thrash + # 3 up = healthy. 1 or 2 up = a leg wedged → heal. + if [ "$up" -eq 1 ] || [ "$up" -eq 2 ]; then + logger -t guest-leg-watchdog "$name degraded: pub=$(bound "$pub" && echo up || echo DOWN) vleg=$(bound "$vleg" && echo up || echo DOWN) aleg=$(bound "$aleg" && echo up || echo DOWN)" + heal_chain "$name" + fi + done + sleep "$POLL_SECONDS" +done From 2ca4ac0c0b1290731d60fa2c84596f9371b85d73 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 05:24:35 +0000 Subject: [PATCH 17/37] fix(watchdog): require STRIKES consecutive degraded polls before healing (ignore idle-cycle blips) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An idle guest (no publisher) cycles its audio/video core — the SRT src exits 'not-linked' each loop and the leg listener briefly unbinds between cycles. That's NORMAL, not a wedge. The first watchdog healed on a single partial poll → it fought the idle cycle. Now a leg must stay partial across STRIKES (default 3 → ~60s) consecutive polls before a heal, and the strike counter resets on any healthy/fully-down poll. Cooldown raised to 90s. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- tools/guest-leg-watchdog.sh | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tools/guest-leg-watchdog.sh b/tools/guest-leg-watchdog.sh index ffbabd1..5a8768b 100644 --- a/tools/guest-leg-watchdog.sh +++ b/tools/guest-leg-watchdog.sh @@ -13,18 +13,28 @@ # guest1 audio leg :9090 dead — both silently blocking joins. # # This watchdog polls every guest's three ports; if a chain is incomplete (public up but -# a leg missing, or vice versa) it restarts that chain in order (audio → core → fanout) -# and logs it. A chain with ALL ports down is treated as "not provisioned" and skipped -# (guest4-6 may legitimately not exist on a smaller deployment). +# a leg missing, or vice versa) PERSISTENTLY it restarts that chain in order +# (audio → core → fanout) and logs it. A chain with ALL ports down is treated as "not +# provisioned" and skipped (guest4-6 may legitimately not exist on a smaller deployment). +# +# IMPORTANT — don't fight the idle cycle: when a guest has NO publisher, its audio/video +# core wrapper loops (`while :; do python3 guest_*.py ...; sleep 3; done`) and the SRT +# source exits "not-linked (-1)" each cycle, so the leg listener briefly unbinds between +# cycles. That's NORMAL for an idle slot — NOT a wedge. So we only heal a leg that stays +# down across STRIKES consecutive polls; a one-poll blip (the idle cycle) is ignored. # # Config (env-overridable; defaults match the box): # GUEST_PORTS per-guest "name:pub:vleg:aleg" specs, comma-separated # POLL_SECONDS default 20 -# COOLDOWN_SECONDS min seconds between restarts of the SAME chain (default 60) +# STRIKES consecutive degraded polls before healing (default 3 → ~60s of +# persistent degrade, well past any idle-cycle blip) +# COOLDOWN_SECONDS min seconds between restarts of the SAME chain (default 90) set -u POLL_SECONDS=${POLL_SECONDS:-20} -COOLDOWN_SECONDS=${COOLDOWN_SECONDS:-60} +STRIKES=${STRIKES:-3} +COOLDOWN_SECONDS=${COOLDOWN_SECONDS:-90} +declare -A strike # name:public:video-leg:audio-leg (the box's scheme; guest1/2 contiguous, 3-6 bolt-ons) GUEST_PORTS=${GUEST_PORTS:-"guest1:8890:8990:9090,guest2:8891:8991:9091,guest3:8895:8995:9095,guest4:8896:8996:9096,guest5:8897:8997:9097,guest6:8898:8998:9098"} @@ -60,11 +70,19 @@ while :; do bound "$vleg" && up=$((up+1)) bound "$aleg" && up=$((up+1)) - # 0 up = container exists but nothing bound (starting up) → give it a cycle, don't thrash - # 3 up = healthy. 1 or 2 up = a leg wedged → heal. + # 0 up = container exists but nothing bound (starting up / idle between cycles) → not + # a wedge; reset strikes. 3 up = healthy; reset. 1-2 up = PARTIAL — count a + # strike, and only heal once it's been partial for STRIKES consecutive polls + # (so the normal idle-cycle unbind, which clears within a poll, never heals). if [ "$up" -eq 1 ] || [ "$up" -eq 2 ]; then - logger -t guest-leg-watchdog "$name degraded: pub=$(bound "$pub" && echo up || echo DOWN) vleg=$(bound "$vleg" && echo up || echo DOWN) aleg=$(bound "$aleg" && echo up || echo DOWN)" - heal_chain "$name" + strike[$name]=$(( ${strike[$name]:-0} + 1 )) + logger -t guest-leg-watchdog "$name degraded (${strike[$name]}/${STRIKES}): pub=$(bound "$pub" && echo up || echo DOWN) vleg=$(bound "$vleg" && echo up || echo DOWN) aleg=$(bound "$aleg" && echo up || echo DOWN)" + if [ "${strike[$name]}" -ge "$STRIKES" ]; then + heal_chain "$name" + strike[$name]=0 + fi + else + strike[$name]=0 fi done sleep "$POLL_SECONDS" From 763f303b13d81634bc3001237c69ae40f1099e71 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 05:29:12 +0000 Subject: [PATCH 18/37] fix(watchdog): give up after GIVE_UP heals that don't stick (chronic flap = stop thrashing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guest1's audio leg flaps 'not-linked' because the Makito sends video-only — its audio core cycles forever and a restart can't fix it. Without a cap the watchdog would restart guest1 endlessly. Now: after GIVE_UP (4) heals that don't make it healthy, log + leave it alone; a chain that reaches 3/3 resets its count so genuine transient wedges always get healed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- tests/test_guest_leg_watchdog.py | 10 ++++++++++ tools/guest-leg-watchdog.sh | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/test_guest_leg_watchdog.py b/tests/test_guest_leg_watchdog.py index 275554e..0cf860c 100644 --- a/tests/test_guest_leg_watchdog.py +++ b/tests/test_guest_leg_watchdog.py @@ -53,6 +53,16 @@ def test_heals_only_partial_chains_not_fully_down(): assert not re.search(r'"\$up"\s*-eq\s*0.*heal', s), "must NOT heal a fully-down (absent) chain" +def test_gives_up_on_chronically_flapping_chain(): + """A leg that a restart can't fix (e.g. a video-only hardware encoder whose audio core + cycles 'not-linked' forever) must not be restarted endlessly — give up after GIVE_UP + heals and log, so it doesn't churn CPU. A chain going healthy resets the count.""" + s = _src() + assert "GIVE_UP" in s, "no give-up cap on repeated heals" + assert "GIVE UP" in s, "must log when abandoning a chronic chain" + assert "heal_count[$name]=0" in s, "a healthy chain must reset its heal count (forgive transient wedges)" + + def test_watches_all_six_guest_chains_by_default(): """The default port map must cover guest1-6 with their real (non-contiguous) ports — guest3 on :8895, not the computed :8892.""" diff --git a/tools/guest-leg-watchdog.sh b/tools/guest-leg-watchdog.sh index 5a8768b..47b75d7 100644 --- a/tools/guest-leg-watchdog.sh +++ b/tools/guest-leg-watchdog.sh @@ -40,6 +40,13 @@ declare -A strike GUEST_PORTS=${GUEST_PORTS:-"guest1:8890:8990:9090,guest2:8891:8991:9091,guest3:8895:8995:9095,guest4:8896:8996:9096,guest5:8897:8997:9097,guest6:8898:8998:9098"} declare -A last_heal +declare -A heal_count +# GIVE_UP: after this many heals that DON'T make a chain healthy, stop restarting it and +# just log — some legs flap for reasons a restart can't fix (e.g. a hardware encoder that +# sends video-only, so its audio core cycles 'not-linked' forever). Thrashing it endlessly +# only churns CPU. A chain that goes healthy resets its count, so transient wedges always +# get healed; only a chronic one is abandoned. +GIVE_UP=${GIVE_UP:-4} bound() { ss -ulnp 2>/dev/null | grep -q ":$1 "; } # is a UDP port listening? @@ -51,8 +58,14 @@ heal_chain() { logger -t guest-leg-watchdog "skip $name — in cooldown ($((now - last))s < ${COOLDOWN_SECONDS}s)" return fi + heal_count[$name]=$(( ${heal_count[$name]:-0} + 1 )) + if [ "${heal_count[$name]}" -gt "$GIVE_UP" ]; then + logger -t guest-leg-watchdog "GIVE UP on $name — healed ${heal_count[$name]}x without it staying healthy; leaving it alone (restart won't fix). Investigate manually." + last_heal[$name]=$now # keep cooldown so the give-up message doesn't spam + return + fi last_heal[$name]=$now - logger -t guest-leg-watchdog "HEALING $name chain (a leg was down) — restart audio→core→fanout" + logger -t guest-leg-watchdog "HEALING $name chain (heal #${heal_count[$name]}) — restart audio→core→fanout" # order matters: the cores (leg listeners) must be up before the fanout tries to write docker restart "${name}-audio" "${name}" "${name}-fanout" >/dev/null 2>&1 } @@ -81,8 +94,11 @@ while :; do heal_chain "$name" strike[$name]=0 fi - else + elif [ "$up" -eq 3 ]; then strike[$name]=0 + heal_count[$name]=0 # healthy again → forgive past heals (transient wedge recovered) + else + strike[$name]=0 # 0 up = absent/starting; don't count heals against it fi done sleep "$POLL_SECONDS" From 003a7cfc5627831d1bd13ee6cef5a0f8ee80552c Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 05:51:15 +0000 Subject: [PATCH 19/37] =?UTF-8?q?fix(watchdog):=20rewrite=20conservative?= =?UTF-8?q?=20=E2=80=94=20fanout-only,=20never=20touch=20live-video=20chai?= =?UTF-8?q?ns,=20OFF=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HARD LESSON (HW Oct 9): the first watchdog restarted whole guest chains for leg flaps. A chain restart recreates MXL flows → the input-selector re-wires → doing that every few seconds CHURNED the selector (router thread restarting in a loop) → PGM stuck, cuts failed, multiview flapping, and the selector API went unresponsive (HTTP 000). The watchdog built to help was breaking the facility. Rewrite is minimal + safe: - watches ONLY the public SRT listener (what a contributor connects to) - NEVER restarts a chain whose video flow is advancing (live source = hands off) - heal restarts ONLY the fanout, not the cores (so flows aren't recreated → no selector churn) - acts only when public listener is down AND no live video, across STRIKES polls - OFF by default (ENABLE=1 to arm); give-up + 300s cooldown Tests rewritten to lock these invariants. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- tests/test_guest_leg_watchdog.py | 67 +++++++-------- tools/guest-leg-watchdog.sh | 143 +++++++++++++++---------------- 2 files changed, 100 insertions(+), 110 deletions(-) diff --git a/tests/test_guest_leg_watchdog.py b/tests/test_guest_leg_watchdog.py index 0cf860c..4ef2970 100644 --- a/tests/test_guest_leg_watchdog.py +++ b/tests/test_guest_leg_watchdog.py @@ -1,9 +1,11 @@ """guest-leg-watchdog.sh invariants (text-scanned — it's bash, no deps). -The watchdog heals the SRT-leg wedge that silently blocks phone joins: a guest ingest -chain is fanout(public) + core(video leg) + audio(audio leg), and if EITHER internal leg -is down the fanout rejects the whole incoming connection. These checks guard the safety -properties that keep the watchdog from thrashing or healing the wrong thing. +HARD LESSON baked into these tests (HW Oct 9): restarting a guest chain recreates its MXL +flows → the input-selector re-wires → if that happens repeatedly the selector churns and +the whole facility destabilizes (PGM sticks, cuts fail, thumbnails flap). The first +watchdog restarted whole chains on any leg blip and broke the facility. So the watchdog is +now deliberately MINIMAL: it only watches the PUBLIC listener, never touches a chain with +live video, restarts ONLY the fanout (not the cores), and is OFF by default. """ import re from pathlib import Path @@ -21,52 +23,49 @@ def test_exists_and_bash_shebang(): assert s.startswith("#!/usr/bin/env bash") or s.startswith("#!/bin/bash") -def test_restarts_chain_in_dependency_order(): - """The cores (leg listeners) must come up before the fanout tries to write to them, - or the fanout immediately re-wedges. Order must be audio, core, fanout.""" +def test_off_by_default(): + """It's a sharp tool — must do nothing unless deliberately enabled (ENABLE=1).""" s = _src() - m = re.search(r'docker restart\s+"\$\{name\}-audio"\s+"\$\{name\}"\s+"\$\{name\}-fanout"', s) - assert m, "heal must restart ${name}-audio ${name} ${name}-fanout in that order" + assert re.search(r'ENABLE=\$\{ENABLE:-0\}', s), "watchdog must default ENABLE=0 (observe-only)" + assert 'ENABLE" = "1"' in s or "ENABLE\" == \"1\"" in s, "heal must be gated on ENABLE=1" -def test_has_cooldown_to_avoid_thrash(): - """A flapping chain must not be restarted every poll — a per-chain cooldown guards it.""" +def test_never_touches_a_chain_with_live_video(): + """The bug that broke the facility: restarting a chain whose video is live churns the + selector. The watchdog MUST skip a chain whose video flow is advancing.""" s = _src() - assert "COOLDOWN_SECONDS" in s, "no cooldown guard" - assert "last_heal" in s, "cooldown must track last heal time per chain" + assert "video_advancing" in s, "no live-video guard" + # the guard must cause a skip/continue, not a heal + assert re.search(r'video_advancing "\$vuuid".*;\s*then\s*strike\[\$name\]=0;\s*continue', s, re.S), \ + "a chain with advancing video must be skipped, never healed" -def test_skips_absent_guests(): - """guest4-6 may not exist on a smaller deployment — the watchdog must skip a guest - whose containers aren't present rather than trying to restart nothing.""" +def test_restarts_only_the_fanout_not_the_cores(): + """Restarting the cores recreates the MXL flows → selector churn. Heal must restart ONLY + the public-listener fanout, leaving the cores (and their flows) untouched.""" s = _src() - assert "docker ps" in s and "grep -qx" in s, "must check the container exists before healing" - assert "continue" in s, "absent guest must be skipped (continue)" + assert re.search(r'docker restart "\$\{name\}-fanout"', s), "heal must restart the fanout" + assert not re.search(r'docker restart "\$\{name\}-audio"\s+"\$\{name\}"', s), \ + "heal must NOT restart the cores (that recreates flows and churns the selector)" -def test_heals_only_partial_chains_not_fully_down(): - """0 ports up = not provisioned / still starting (skip); 1-2 up = a leg wedged (heal); - 3 up = healthy. Healing a fully-down chain would thrash a guest that's just absent.""" +def test_only_acts_on_a_dead_public_listener(): + """The only thing that silently blocks a join is the PUBLIC listener being down on an + idle slot. Internal leg flaps are explicitly NOT the watchdog's concern anymore.""" s = _src() - assert re.search(r'"\$up"\s*-eq\s*1', s) and re.search(r'"\$up"\s*-eq\s*2', s), \ - "must heal when exactly 1 or 2 of 3 ports are up" - assert not re.search(r'"\$up"\s*-eq\s*0.*heal', s), "must NOT heal a fully-down (absent) chain" + assert re.search(r'if bound "\$pub"; then', s), "must key off the public listener being up" -def test_gives_up_on_chronically_flapping_chain(): - """A leg that a restart can't fix (e.g. a video-only hardware encoder whose audio core - cycles 'not-linked' forever) must not be restarted endlessly — give up after GIVE_UP - heals and log, so it doesn't churn CPU. A chain going healthy resets the count.""" +def test_has_strikes_cooldown_and_giveup(): s = _src() - assert "GIVE_UP" in s, "no give-up cap on repeated heals" - assert "GIVE UP" in s, "must log when abandoning a chronic chain" - assert "heal_count[$name]=0" in s, "a healthy chain must reset its heal count (forgive transient wedges)" + assert "STRIKES" in s and "COOLDOWN_SECONDS" in s and "GIVE_UP" in s, \ + "must have strikes + cooldown + give-up guards against thrash" -def test_watches_all_six_guest_chains_by_default(): - """The default port map must cover guest1-6 with their real (non-contiguous) ports — - guest3 on :8895, not the computed :8892.""" +def test_watches_all_six_guest_chains_with_real_ports(): + """Default map covers guest1-6 on their REAL (non-contiguous) public ports + video UUIDs.""" s = _src() for spec in ("guest1:8890", "guest2:8891", "guest3:8895", "guest4:8896", "guest5:8897", "guest6:8898"): assert spec in s, f"default GUEST_PORTS missing {spec}" + assert "9e333e00" in s, "guest3 video UUID must be in the map (for the live-video check)" diff --git a/tools/guest-leg-watchdog.sh b/tools/guest-leg-watchdog.sh index 47b75d7..49c78ff 100644 --- a/tools/guest-leg-watchdog.sh +++ b/tools/guest-leg-watchdog.sh @@ -1,104 +1,95 @@ #!/usr/bin/env bash -# guest-leg-watchdog — keeps each guest's SRT ingest chain healthy so a contributor -# (phone/Larix) can always connect. +# guest-leg-watchdog — conservatively keeps a guest's PUBLIC SRT listener available so a +# contributor (phone/Larix) can connect to a FREE slot. # -# THE WEDGE IT HEALS (HW Oct 9): each guest ingest is a 3-container chain — -# -fanout (public SRT listener :PUB) → splits A/V to two internal legs -# (video core, listens :VLEG) → writes the guest VIDEO flow -# -audio (audio core, listens :ALEG) → writes the guest AUDIO flow -# guest_av_listen.sh writes to BOTH legs; if EITHER leg's listener is down, the -# fanout's SRT write fails ("Socket is broken or closed, Trying to reconnect") and it -# REJECTS the whole incoming connection — so a phone (and even a clean local caller) -# gets "Input/output error" and never appears. Seen live: guest2 audio leg :9091 dead, -# guest1 audio leg :9090 dead — both silently blocking joins. +# ⚠️ HARD LESSON (HW Oct 9): restarting a guest chain RECREATES its MXL flows, which forces +# the input-selector to RE-WIRE (router thread restarts). Do that every few seconds and the +# selector never stabilizes → PGM sticks, cuts fail, multiview thumbnails flap. The first +# version of this watchdog restarted a chain whenever any internal leg blipped (which is +# NORMAL for an idle slot, and ALSO for a video-only hardware encoder whose audio core +# cycles 'not-linked' forever) — so it churned the selector and broke the whole facility. # -# This watchdog polls every guest's three ports; if a chain is incomplete (public up but -# a leg missing, or vice versa) PERSISTENTLY it restarts that chain in order -# (audio → core → fanout) and logs it. A chain with ALL ports down is treated as "not -# provisioned" and skipped (guest4-6 may legitimately not exist on a smaller deployment). +# So this version is deliberately MINIMAL and SAFE: +# • It only cares about the PUBLIC listener (:PUB) — the thing a contributor connects to. +# Internal legs flapping is NOT our business (that's the idle cycle / source quirks). +# • It NEVER restarts a chain whose VIDEO flow is advancing (a live source → hands off). +# • It only restarts a chain that is BOTH: public port missing AND video flow not +# advancing AND stayed that way across STRIKES consecutive polls — i.e. a genuinely +# dead listener on an idle slot, the only case that silently blocks a new join. +# • It gives up after GIVE_UP restarts that don't help, and has a long cooldown. +# • OFF by default. Enable deliberately: ENABLE=1 (or the systemd unit sets it). # -# IMPORTANT — don't fight the idle cycle: when a guest has NO publisher, its audio/video -# core wrapper loops (`while :; do python3 guest_*.py ...; sleep 3; done`) and the SRT -# source exits "not-linked (-1)" each cycle, so the leg listener briefly unbinds between -# cycles. That's NORMAL for an idle slot — NOT a wedge. So we only heal a leg that stays -# down across STRIKES consecutive polls; a one-poll blip (the idle cycle) is ignored. -# -# Config (env-overridable; defaults match the box): -# GUEST_PORTS per-guest "name:pub:vleg:aleg" specs, comma-separated -# POLL_SECONDS default 20 -# STRIKES consecutive degraded polls before healing (default 3 → ~60s of -# persistent degrade, well past any idle-cycle blip) -# COOLDOWN_SECONDS min seconds between restarts of the SAME chain (default 90) +# Honestly: on this box the better fix is usually to leave guests alone and let a CONNECTING +# contributor's SRT handshake wake the listener. Use this only if idle listeners are +# observed to die outright. Config (env): +# ENABLE=1 required to do anything (default 0 = observe-only, just logs) +# GUEST_PORTS "name:pub:vleg:aleg" specs, comma-separated +# POLL_SECONDS=30 STRIKES=4 GIVE_UP=3 COOLDOWN_SECONDS=300 set -u -POLL_SECONDS=${POLL_SECONDS:-20} -STRIKES=${STRIKES:-3} -COOLDOWN_SECONDS=${COOLDOWN_SECONDS:-90} -declare -A strike +ENABLE=${ENABLE:-0} +POLL_SECONDS=${POLL_SECONDS:-30} +STRIKES=${STRIKES:-4} +GIVE_UP=${GIVE_UP:-3} +COOLDOWN_SECONDS=${COOLDOWN_SECONDS:-300} +SELECTOR_CONTAINER=${SELECTOR_CONTAINER:-input-selector} +DOMAIN=${MXL_DOMAIN:-/mxl-domain} +MXL_INFO=${MXL_INFO:-/opt/mxl/tools/mxl-info/mxl-info} + +# name:public:video-leg:audio-leg : video-flow-uuid +GUEST_PORTS=${GUEST_PORTS:-"guest1:8890:8990:9090:9e111e00-aaaa-4bbb-8ccc-000000000001,guest2:8891:8991:9091:9e222e00-aaaa-4bbb-8ccc-000000000001,guest3:8895:8995:9095:9e333e00-aaaa-4bbb-8ccc-000000000001,guest4:8896:8996:9096:9e444e00-aaaa-4bbb-8ccc-000000000001,guest5:8897:8997:9097:9e555e00-aaaa-4bbb-8ccc-000000000001,guest6:8898:8998:9098:9e666e00-aaaa-4bbb-8ccc-000000000001"} -# name:public:video-leg:audio-leg (the box's scheme; guest1/2 contiguous, 3-6 bolt-ons) -GUEST_PORTS=${GUEST_PORTS:-"guest1:8890:8990:9090,guest2:8891:8991:9091,guest3:8895:8995:9095,guest4:8896:8996:9096,guest5:8897:8997:9097,guest6:8898:8998:9098"} +declare -A last_heal heal_count strike -declare -A last_heal -declare -A heal_count -# GIVE_UP: after this many heals that DON'T make a chain healthy, stop restarting it and -# just log — some legs flap for reasons a restart can't fix (e.g. a hardware encoder that -# sends video-only, so its audio core cycles 'not-linked' forever). Thrashing it endlessly -# only churns CPU. A chain that goes healthy resets its count, so transient wedges always -# get healed; only a chronic one is abandoned. -GIVE_UP=${GIVE_UP:-4} +bound() { ss -ulnp 2>/dev/null | grep -q ":$1 "; } -bound() { ss -ulnp 2>/dev/null | grep -q ":$1 "; } # is a UDP port listening? +head_idx() { # current head index of a flow, or empty + docker exec "$SELECTOR_CONTAINER" sh -c "$MXL_INFO -d $DOMAIN -f $1 2>/dev/null" 2>/dev/null \ + | grep -i 'Head index' | grep -oE '[0-9]+' | head -1 +} +video_advancing() { # is the guest's VIDEO flow moving? (a live source — never touch it) + local uuid=$1 a b + a=$(head_idx "$uuid"); sleep 1; b=$(head_idx "$uuid") + [ -n "$a" ] && [ -n "$b" ] && [ "$b" -gt "$a" ] 2>/dev/null +} heal_chain() { - local name=$1 - local now; now=$(date +%s) + local name=$1 now; now=$(date +%s) local last=${last_heal[$name]:-0} - if [ $((now - last)) -lt "$COOLDOWN_SECONDS" ]; then - logger -t guest-leg-watchdog "skip $name — in cooldown ($((now - last))s < ${COOLDOWN_SECONDS}s)" - return - fi + [ $((now - last)) -lt "$COOLDOWN_SECONDS" ] && { logger -t guest-leg-watchdog "skip $name — cooldown"; return; } heal_count[$name]=$(( ${heal_count[$name]:-0} + 1 )) if [ "${heal_count[$name]}" -gt "$GIVE_UP" ]; then - logger -t guest-leg-watchdog "GIVE UP on $name — healed ${heal_count[$name]}x without it staying healthy; leaving it alone (restart won't fix). Investigate manually." - last_heal[$name]=$now # keep cooldown so the give-up message doesn't spam - return + logger -t guest-leg-watchdog "GIVE UP on $name (${heal_count[$name]}x) — restart won't fix; leaving alone" + last_heal[$name]=$now; return fi last_heal[$name]=$now - logger -t guest-leg-watchdog "HEALING $name chain (heal #${heal_count[$name]}) — restart audio→core→fanout" - # order matters: the cores (leg listeners) must be up before the fanout tries to write - docker restart "${name}-audio" "${name}" "${name}-fanout" >/dev/null 2>&1 + logger -t guest-leg-watchdog "HEAL $name public listener (heal #${heal_count[$name]}) — restart fanout only" + # Restart ONLY the fanout (the public listener). Leave the cores alone so we don't + # recreate the MXL flows and churn the selector. + docker restart "${name}-fanout" >/dev/null 2>&1 } -logger -t guest-leg-watchdog "started — watching: $GUEST_PORTS (poll ${POLL_SECONDS}s)" +logger -t guest-leg-watchdog "started (ENABLE=$ENABLE, poll ${POLL_SECONDS}s) — watching PUBLIC listeners only; never touches a chain with live video" while :; do IFS=',' read -ra specs <<< "$GUEST_PORTS" for spec in "${specs[@]}"; do - IFS=':' read -r name pub vleg aleg <<< "$spec" - # skip guests that don't exist as containers on this deployment + IFS=':' read -r name pub vleg aleg vuuid <<< "$spec" docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "${name}-fanout" || continue - up=0 - bound "$pub" && up=$((up+1)) - bound "$vleg" && up=$((up+1)) - bound "$aleg" && up=$((up+1)) + # Only concern: is the PUBLIC listener up? If yes, a contributor can connect → done. + if bound "$pub"; then strike[$name]=0; heal_count[$name]=0; continue; fi + + # Public listener is DOWN. If the video flow is advancing, a source is live on this + # slot anyway — do NOT touch it (restarting would churn the selector). + if video_advancing "$vuuid"; then strike[$name]=0; continue; fi - # 0 up = container exists but nothing bound (starting up / idle between cycles) → not - # a wedge; reset strikes. 3 up = healthy; reset. 1-2 up = PARTIAL — count a - # strike, and only heal once it's been partial for STRIKES consecutive polls - # (so the normal idle-cycle unbind, which clears within a poll, never heals). - if [ "$up" -eq 1 ] || [ "$up" -eq 2 ]; then - strike[$name]=$(( ${strike[$name]:-0} + 1 )) - logger -t guest-leg-watchdog "$name degraded (${strike[$name]}/${STRIKES}): pub=$(bound "$pub" && echo up || echo DOWN) vleg=$(bound "$vleg" && echo up || echo DOWN) aleg=$(bound "$aleg" && echo up || echo DOWN)" - if [ "${strike[$name]}" -ge "$STRIKES" ]; then - heal_chain "$name" - strike[$name]=0 - fi - elif [ "$up" -eq 3 ]; then + # Public down AND no live video = an idle slot whose listener died = a real join block. + strike[$name]=$(( ${strike[$name]:-0} + 1 )) + logger -t guest-leg-watchdog "$name public listener :$pub DOWN, no live video (${strike[$name]}/${STRIKES})" + if [ "${strike[$name]}" -ge "$STRIKES" ]; then + if [ "$ENABLE" = "1" ]; then heal_chain "$name"; else + logger -t guest-leg-watchdog "$name would heal, but ENABLE!=1 (observe-only)"; fi strike[$name]=0 - heal_count[$name]=0 # healthy again → forgive past heals (transient wedge recovered) - else - strike[$name]=0 # 0 up = absent/starting; don't count heals against it fi done sleep "$POLL_SECONDS" From 1f9b5ed7051b00a258adc4074b0454ec43803689 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Fri, 9 Oct 2026 06:01:16 +0000 Subject: [PATCH 20/37] security(review): remove live hostname from docs + bind mediamtx API to localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #41: - docs/index.html + docs/what-are-grains.html no longer hardcode the live server hostname (placeholder YOUR-SWITCHER-HOST; health page reads window.MXL_HEALTH_API override). The live mxlswitcher.com public/ copies keep the real URL (box-specific, like facility.json). - quickstart: mediamtx API now binds 127.0.0.1:9997 (MTX_APIADDRESS), not *:9997 — with --network host the default exposed an unauthenticated control API on the LAN. The Core reads it via localhost. Applied to the live box too. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT --- docs/index.html | 2 +- docs/what-are-grains.html | 3 ++- scripts/quickstart.sh | 12 ++++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/index.html b/docs/index.html index aabfd53..e3b79b3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -87,7 +87,7 @@

What if the switcher
was shared memory?

A working exploration of a live production control room built around the Media eXchange Layer: real cameras, remote guests, playout, layouts, graphics, audio, multiview, recording and standard control — assembled as software on general-purpose cloud VMs.

diff --git a/docs/what-are-grains.html b/docs/what-are-grains.html index 26a5742..194cf2a 100644 --- a/docs/what-are-grains.html +++ b/docs/what-are-grains.html @@ -87,7 +87,8 @@

The point, for the skeptics