Sunday auto-fix (gentle fixes only), reviewed; PROPRESENTER key label

Sundays 06:30-13:30, with ALARMS on, a fault that has lasted 20 s is fixed
by itself if the fix is gentle (reconnects, HyperDeck off/on, Tascam, MIDI,
rtpMIDI, starting a closed Vista/Resolume/OBS/Ableton/Translate, DVS on one
Mac). At most 2 tries 90 s apart; key shows AUTO-FIXING. Anything that
closes a running program stays a hold (load_config refuses it).

A review before going live (2 lenses, each finding verified) confirmed and
this fixes: a Dante-wide "gone" (Ingest's view) would restart DVS on every
Mac every minute - several devices gone at once now means hands off; the
try limit reset whenever a fix made its fault blink - tries are kept until
the fault has been gone 10 min; DVS on Ingest kills ProdCom - removed from
the auto list; restarting dead connections looked like a network burst -
only a connection good in the last 30 s counts; a restart key could kill
an app auto-fix was starting; concurrent MIDI reconnects overwrote each
other - now one worker. Auto-fix errors are caught so the panel keeps
running. 105 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 07:05:05 -04:00
co-authored by Claude Opus 5.5
parent 3417f00f1b
commit 560e0401a3
4 changed files with 274 additions and 10 deletions
+138 -1
View File
@@ -566,6 +566,19 @@ class TestBehaviour(unittest.TestCase):
self.assertEqual(engine.restart_targets("ingest"), [])
def mock_comp():
class Comp:
def __init__(self):
self.restarted, self.enabled = [], []
def restart_connection(self, cid):
self.restarted.append(cid)
return True
def enable_connection(self, cid):
self.enabled.append(cid)
return True
return Comp()
class TestFixes(unittest.TestCase):
"""Hold = fix only what that button is failing on. Nothing here runs a real fix."""
@@ -660,6 +673,130 @@ class TestFixes(unittest.TestCase):
open(os.path.join(os.path.dirname(os.path.abspath(sp.__file__)), "remote-access", "mac-dvs-helper.sh")).read())
self.assertIn("open -g /Applications/ProdCom.app", CONFIG["fixes"]["dvs_ingest"]["run"]) # ProdCom after DVS
def auto_setup(self):
ran = []
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
comp = mock_comp()
return ran, comp
def test_auto_fix_sunday_service_gentle_fixes_only(self):
ran, comp = self.auto_setup()
self.snap.remote["tracks\0pgrep -xq Live"] = False # Ableton closed: gentle
self.snap.http["http://10.6.7.13:8080/"] = False # Bible app: restart = a hold
self.snap.conns["x32"]["category"] = "error" # a reconnect: gentle
t = SUNDAY # Sunday 10:00, alarms on
run_steps(self.engine, self.snap, t, 10)
self.assertEqual(self.engine.auto_fix(self.snap, comp, t + 10), []) # not 20 s yet
done = self.engine.auto_fix(self.snap, comp, t + 30)
time.sleep(0.2)
self.assertEqual(sorted(done), ["apps", "x32"])
self.assertEqual(ran, ["ableton"]) # never "bibleapp"
self.assertEqual(comp.restarted, ["id-x32"])
# while it works, the key says it is fixing itself
gate = threading.Event()
self.engine.executor = lambda cfg, fix_id, chk: gate.wait(2) or True
self.engine.auto_fix(self.snap, comp, t + 130) # second try, 90 s later
self.assertEqual(self.engine.notes["apps"]["text"], "AUTO-FIXING\nABLETON")
gate.set()
def test_auto_fix_tries_twice_then_leaves_it_to_you(self):
ran, comp = self.auto_setup()
self.snap.remote["tracks\0pgrep -xq Live"] = False
t = SUNDAY
run_steps(self.engine, self.snap, t, 25)
for dt in (25, 60, 120, 230, 400): # 90 s apart, at most twice
self.engine.auto_fix(self.snap, comp, t + dt)
time.sleep(0.2)
self.assertEqual(ran, ["ableton", "ableton"])
self.snap.remote["tracks\0pgrep -xq Live"] = True # fixed (by hand)...
run_steps(self.engine, self.snap, t + 401, 2)
self.engine.auto_fix(self.snap, comp, t + 410)
self.assertIn(("fix", "ableton", ""), self.engine.auto_tries) # a blink does not reset it
self.engine.auto_fix(self.snap, comp, t + 1100)
self.assertEqual(self.engine.auto_tries, {}) # gone 10 min: fresh tries
def test_auto_fix_count_survives_its_own_fault_blinking_out(self):
ran, comp = self.auto_setup()
t = SUNDAY
self.snap.conns["x32"]["category"] = "error"
run_steps(self.engine, self.snap, t, 25)
for dt in range(25, 900, 30): # each reconnect: error -> starting -> error
self.snap.conns["x32"]["category"] = None if dt % 60 == 55 else "error"
run_steps(self.engine, self.snap, t + dt, 1)
self.engine.auto_fix(self.snap, comp, t + dt + 1)
self.assertEqual(comp.restarted, ["id-x32", "id-x32"]) # still only twice
def test_auto_fix_leaves_dante_alone_when_many_devices_vanish(self):
ran, comp = self.auto_setup()
t = SUNDAY
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(
[d for d in devices if d not in ("NA-Tracks", "NA-ProPresenter-Mac", "BOH-WING")])
run_steps(self.engine, self.snap, t, 30)
self.engine.auto_fix(self.snap, comp, t + 30)
self.assertEqual((ran, comp.restarted), ([], [])) # Ingest's view of Dante: hands off
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(
[d for d in devices if d != "NA-Tracks"]) # just the Tracks Mac's DVS
run_steps(self.engine, self.snap, t + 31, 1)
run_steps(self.engine, self.snap, t + 600, 30)
self.engine.auto_fix(self.snap, comp, t + 630)
time.sleep(0.2)
self.assertEqual(ran, ["dvs_tracks"])
self.assertNotIn("dvs_ingest", CONFIG["auto_fix"]["fixes"]) # it closes ProdCom
def test_midi_reconnects_run_one_after_another(self):
calls = []
def slow(cfg, fix_id, chk):
calls.append((threading.get_ident(), chk["peer"]))
time.sleep(0.05)
return True
self.engine.executor = slow
self.snap.midi["NA Ingest"]["peers"] = [] # Lighting PC and SuperRack gone
_, t = run_steps(self.engine, self.snap, SUNDAY, 4)
self.engine.run_fixes("midi", self.snap, None, t)
time.sleep(0.4)
self.assertEqual(sorted(p for _, p in calls), ["Lighting PC", "SuperRack-LiveBox"])
self.assertEqual(len({tid for tid, _ in calls}), 1) # one worker, not two at once
def test_restart_key_waits_while_auto_fix_starts_that_app(self):
ran, comp = self.auto_setup()
self.snap.remote["tracks\0pgrep -xq Live"] = False
run_steps(self.engine, self.snap, SUNDAY, 25)
self.engine.auto_fix(self.snap, comp, SUNDAY + 25) # auto-fix starts Ableton
self.assertIsNone(self.engine.run_force("rs_ableton", self.snap, None, SUNDAY + 30))
self.assertEqual(self.engine.notes["rs_ableton"]["text"], "STARTING\nWAIT 1 MIN")
time.sleep(0.2)
self.assertEqual(ran, ["ableton"]) # not killed while starting
def test_a_restarted_dead_connection_is_not_a_network_drop(self):
t = SUNDAY
run_steps(self.engine, self.snap, t, 2)
for label in ("x32", "VX1000", "UHDJr", "RESI_Decoder", "tascam"): # dead for a long time
self.snap.conns[label]["category"] = "error"
run_steps(self.engine, self.snap, t + 100, 2)
self.engine.burst, self.engine.conn_drops = None, sp.deque()
for cat in (None, "error"): # auto restarts: starting, error
for label in ("x32", "VX1000", "UHDJr", "RESI_Decoder", "tascam"):
self.snap.conns[label]["category"] = cat
tiles, t2 = run_steps(self.engine, self.snap, t + 200, 2)
self.assertEqual(tiles["net"]["x"], "NET\nOK")
def test_auto_fix_waits_out_the_network_and_respects_alarms_off(self):
ran, comp = self.auto_setup()
self.snap.remote["tracks\0pgrep -xq Live"] = False
t = SUNDAY
run_steps(self.engine, self.snap, t, 25)
self.engine.burst = {"start": t, "conns": set(), "cleared": None} # a network blip going on
self.assertEqual(self.engine.auto_fix(self.snap, comp, t + 30), [])
self.engine.burst = None
self.engine.service.set(False, t + 31) # ALARMS pressed: maintenance
self.assertEqual(self.engine.auto_fix(self.snap, comp, t + 40), [])
self.engine.service.set(True, t + 41)
weekday = SUNDAY - 3 * 86400 # Thursday: holds only
run_steps(self.engine, self.snap, weekday, 25)
self.assertEqual(self.engine.auto_fix(self.snap, comp, weekday + 30), [])
self.assertEqual(ran, [])
def test_restart_keys_flash_when_their_thing_is_down(self):
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
devices.remove("NA-Tracks") # DVS on the Tracks Mac gone
@@ -1371,7 +1508,7 @@ class TestPage(unittest.TestCase):
self.assertEqual(len(holds), len(CONFIG["restart_page"]["keys"]))
self.assertIn("$(custom:sp_cmd);force rs_tascam $(internal:time_unix)", holds)
self.assertTrue(all(up == [] for up in holds.values())) # a tap does nothing
back = c["3"]["0"]["steps"]["0"]["action_sets"]["up"][0]
back = c["2"]["6"]["steps"]["0"]["action_sets"]["up"][0] # where the user put it
self.assertEqual((back["definitionId"], back["options"]["page"]["value"]), ("set_page", 19))
def test_push_page_looks_at_what_is_on_the_page(self):