From 62d8163f10855af9e647a71cd0807814fbe9c3ef Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Sat, 10 Oct 2026 21:21:08 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(healers):=20runtime=20flow-UUID=20disco?= =?UTF-8?q?very=20=E2=80=94=20commit=20pgm-heal=20+=20de-hardcode=20thumbs?= =?UTF-8?q?=20(R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The healers carried facility-specific flow UUIDs, so pgm-heal could never be committed (it only lived on the box) and the thumbs probe baked in one lab's SLOTS — stale UUIDs made the Health panel show dead slots for sources that were never present and miss the ones that were. This is the healer-side of reviewer issue #2 (the backend/quickstart side was fixed via facility_from_discovery.py). Every flow is already self-describing: `mxl-info -l` groups flows under a stable ROLE name (Pattern, Playout, Guest1..N, Guest5Stable, Keyer, Selector) and flow_def.json carries the same role in its grouphint tag. So the healers name ROLES and resolve UUIDs at runtime — portable to any facility. - tools/mxl-flows.sh: NEW sourceable POSIX discovery helper (flow_by_role / flow_present / all_video_roles) over a cached `mxl-info -l` snapshot. - tools/mxl-pgm-heal.sh: NEW (was box-only). Boot/recovery heal — wire selector from DISCOVERED present roles, cut to the first advancing slot, point the encoder at the selector output (keyer bypassed by default), verify advancing. Zero hardcoded UUIDs; correspondent slots prefer GuestNStable (never-interrupt). - tools/mxl_thumbs.py: discover SLOTS from the domain instead of a static dict; a supervisor re-scans so sources appearing AFTER startup get a worker and a role whose flow is recreated with a new UUID re-attaches (fixes the ~2min slow re-attach wedge); exclude selector/keyer (program outputs), collapse GuestNStable -> guestN. Run loop guarded under __main__ so it's importable. - tests/test_thumb_slots.py: pin the discovery contract (7 cases). Suite: 156 pytest + 68 node green. No live-system changes (code+tests only). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017YNw5igfxCp4mUoMMMM9pv --- tests/test_thumb_slots.py | 88 +++++++++++++++++++ tools/mxl-flows.sh | 67 +++++++++++++++ tools/mxl-pgm-heal.sh | 174 ++++++++++++++++++++++++++++++++++++++ tools/mxl_thumbs.py | 143 ++++++++++++++++++++++++++----- 4 files changed, 453 insertions(+), 19 deletions(-) create mode 100644 tests/test_thumb_slots.py create mode 100755 tools/mxl-flows.sh create mode 100755 tools/mxl-pgm-heal.sh diff --git a/tests/test_thumb_slots.py b/tests/test_thumb_slots.py new file mode 100644 index 0000000..6707ce9 --- /dev/null +++ b/tests/test_thumb_slots.py @@ -0,0 +1,88 @@ +"""mxl_thumbs.py discovers its multiview slots from the live domain (reviewer R2: +no hardcoded, facility-specific UUIDs). These tests pin the discovery contract +against a synthetic domain of flow_def.json files — no GStreamer, no threads. + +discover_slots() is imported from the probe module; the run loop is guarded under +__main__, and Gst.init happens there, so importing for the test starts nothing. +""" +import importlib +import json +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +TOOLS = REPO / "tools" + +pytest.importorskip("gi") # the probe imports GStreamer bindings at module top + + +@pytest.fixture(scope="module") +def mod(): + sys.path.insert(0, str(TOOLS)) + try: + m = importlib.import_module("mxl_thumbs") + finally: + sys.path.pop(0) + return m + + +def _flow(domain, uuid, fmt, grouphint=None, label=""): + d = domain / f"{uuid}.mxl-flow" + d.mkdir() + body = {"id": uuid, "format": f"urn:x-nmos:format:{fmt}", "label": label} + if grouphint: + body["tags"] = {"urn:x-nmos:tag:grouphint/v1.0": [grouphint]} + (d / "flow_def.json").write_text(json.dumps(body)) + + +def test_discovers_video_sources_by_role(mod, tmp_path): + _flow(tmp_path, "d3e15194-6d1f-5955-8d52-52e7076b7a99", "video", "Pattern:Video", "Pattern Video") + _flow(tmp_path, "9998da48-9041-5ae1-b730-d0a918563107", "video", "Playout:Video", "Clip Video") + _flow(tmp_path, "9e111e00-aaaa-4bbb-8ccc-000000000001", "video", "Guest1:Video", "Guest 1") + slots = mod.discover_slots(str(tmp_path)) + assert slots == { + "pattern": "d3e15194-6d1f-5955-8d52-52e7076b7a99", + "playout": "9998da48-9041-5ae1-b730-d0a918563107", + "guest1": "9e111e00-aaaa-4bbb-8ccc-000000000001", + } + + +def test_audio_flows_are_skipped(mod, tmp_path): + _flow(tmp_path, "d3e15194-6d1f-5955-8d52-52e7076b7a99", "video", "Pattern:Video") + _flow(tmp_path, "7c1dd9ba-ca2c-5737-889b-abdd23c06020", "audio", "Pattern:Audio") + slots = mod.discover_slots(str(tmp_path)) + assert list(slots) == ["pattern"] # the audio flow produced no tile + + +def test_program_outputs_excluded(mod, tmp_path): + """selector/keyer are the program bus, not selectable source tiles.""" + _flow(tmp_path, "5a617f19-7c48-598c-9e94-4611c6316273", "video", "Selector:Video", "Selector PGM") + _flow(tmp_path, "86efffa4-9b4e-558c-8816-7e200e20cf17", "video", "Keyer:Video", "Keyer PGM") + _flow(tmp_path, "9e333e00-aaaa-4bbb-8ccc-000000000001", "video", "Guest3:Video", "Guest 3") + slots = mod.discover_slots(str(tmp_path)) + assert list(slots) == ["guest3"] + + +def test_stable_collapses_to_friendly_guest_name(mod, tmp_path): + """A never-interrupt slot's GuestNStable flow shows under the friendly 'guestN'.""" + _flow(tmp_path, "57ab5e00-aaaa-4bbb-8ccc-000000000001", "video", "Guest5Stable:Video", "Guest 5 Stable") + slots = mod.discover_slots(str(tmp_path)) + assert slots == {"guest5": "57ab5e00-aaaa-4bbb-8ccc-000000000001"} + + +def test_env_force_overrides_discovery(mod, tmp_path): + forced = "cam=aaa,guest1=bbb" + slots = mod.discover_slots(str(tmp_path), forced=forced) + assert slots == {"cam": "aaa", "guest1": "bbb"} + + +def test_missing_domain_is_empty_not_error(mod, tmp_path): + assert mod.discover_slots(str(tmp_path / "does-not-exist")) == {} + + +def test_untagged_flow_falls_back_to_uuid_prefix(mod, tmp_path): + _flow(tmp_path, "abcd1234-6d1f-5955-8d52-52e7076b7a99", "video") # no grouphint + slots = mod.discover_slots(str(tmp_path)) + assert slots == {"abcd1234": "abcd1234-6d1f-5955-8d52-52e7076b7a99"} diff --git a/tools/mxl-flows.sh b/tools/mxl-flows.sh new file mode 100755 index 0000000..bb6a3ad --- /dev/null +++ b/tools/mxl-flows.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env sh +# mxl-flows.sh — runtime flow-UUID discovery for the MXL switcher healers. +# +# WHY: pgm-heal and selfheal used to carry HARDCODED flow UUIDs ("G1=9e111e00…", +# "PATTERN=d3e15194…"). Those UUIDs are box-specific — they are whatever the +# quickstart happened to mint on THIS facility — so a script full of them cannot +# be committed to the shared repo (reviewer issue #2): on any other facility the +# UUIDs are wrong, the selector gets wired to flows that do not exist, and the +# down-selector rebuild wires only pattern/playout while 400ing on the phantom +# guest UUIDs (HW Oct 10). +# +# The facility is already self-describing. `mxl-info -d DOMAIN -l` prints, per +# device group, a stable ROLE name and the flows under it: +# +# Guest1: mxl:///mxl-domain?id=9e111e00-… +# Video : 9e111e00-… - Guest 1 +# Pattern: mxl:///mxl-domain?id=d3e15194-…&id=7c1dd9ba-… +# Video : d3e15194-… - Pattern Video +# Audio : 7c1dd9ba-… - Pattern Audio +# +# The group names (Pattern, Playout, Guest1..GuestN, Guest5Stable, Keyer, +# Selector) are the role identity; the UUID under each is discovered at runtime. +# So the healers name ROLES, never UUIDs, and work on any facility. +# +# Sourced, not executed. POSIX sh. Requires: DOMAIN set, DOCKER set (the caller's +# `docker` or `sudo docker`), SEL_CTR set (the selector container name). + +# mxl_flows_dump — cache a single `mxl-info -l` snapshot in MXL_FLOWS_CACHE so +# repeated lookups in one heal pass cost one docker exec, not one per role. +# Call mxl_flows_refresh at the top of each pass to invalidate it. +MXL_FLOWS_CACHE="" +mxl_flows_refresh(){ MXL_FLOWS_CACHE=""; } +mxl_flows_dump(){ + [ -n "$MXL_FLOWS_CACHE" ] && { printf '%s' "$MXL_FLOWS_CACHE"; return 0; } + MXL_FLOWS_CACHE=$($DOCKER exec "$SEL_CTR" sh -c "/opt/mxl/tools/mxl-info/mxl-info -d $DOMAIN -l" 2>/dev/null) + [ -n "$MXL_FLOWS_CACHE" ] || return 1 + printf '%s' "$MXL_FLOWS_CACHE" +} + +# flow_by_role ROLE [MEDIA] — UUID of the flow for a device group ($1, e.g. +# "Guest1", "Pattern", "Guest5Stable") and media kind ($2, Video|Audio, default +# Video). Empty string + rc1 if that role/media is not present in the domain. +# +# The group header line is "ROLE: mxl://…"; the flow lines under it are +# "Video : -