From 5db1d9373228086e37dad1064a5b56e07961096f Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 21 Oct 2026 14:26:18 -0400 Subject: [PATCH] Companion keeper: after a restart the panel opens the Companion that ran last if it is not running - only in the watchdog's first 10 minutes, never another version, never during an upgrade - SC verified end to end; laptop SSH to the SC Macs; configs synced Co-Authored-By: Claude Opus 5.5 --- settings/campus.py | 7 +++++ status_panel.py | 53 ++++++++++++++++++++++++++++++++++++++ tests/test_status_panel.py | 35 +++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/settings/campus.py b/settings/campus.py index 71cd560..5594cad 100644 --- a/settings/campus.py +++ b/settings/campus.py @@ -292,6 +292,9 @@ def upgrade_companion(base, say=print, source=None): import urllib.request root = os.path.expanduser("~/Library/Application Support/companion") app = "/Applications/Companion 5.0.7.app" + # The watchdog's CompanionKeeper must not reopen the old Companion in the middle of this. + with open(os.path.join(PARENT, "companion-upgrading"), "w") as f: + f.write(str(time.time())) if not os.path.exists(app) and source: # standalone: the .dmg (or .zip) downloaded from bitfocus.io install_companion_from(source, app, say) if not os.path.exists(app): @@ -323,6 +326,10 @@ def upgrade_companion(base, say=print, source=None): try: discover.companion_get({"companion": {}}, "/api/connections", 2) say("Companion 5.0.7 is running. In System Settings > General > Login Items, replace the old Companion with 5.0.7.") + try: + os.remove(os.path.join(PARENT, "companion-upgrading")) + except OSError: + pass return True except OSError: time.sleep(1) diff --git a/status_panel.py b/status_panel.py index 9de10ce..450f572 100644 --- a/status_panel.py +++ b/status_panel.py @@ -3355,6 +3355,54 @@ def start_artnet_listener(snap, stop, port=6454): return t +def companion_app_running(): + """The .app of the Companion running on this Mac (from ps), or None.""" + try: + out = subprocess.run(["/bin/ps", "-axo", "command"], capture_output=True, text=True, timeout=10).stdout + except (OSError, subprocess.SubprocessError): + return None + for line in out.splitlines(): + m = re.match(r"^(/Applications/Companion[^/]*\.app)/Contents/MacOS/Companion$", line.strip()) + if m: + return m.group(1) + return None + + +class CompanionKeeper: + """After this Mac restarts, Companion is open: the deck is Companion, so a Companion that is not a login + item would leave the whole panel dark (Southcoast, 2026-10-21: its login items cannot be read without a + prompt on its screen). Remembers which copy runs (state "companion_app"; Woonsocket keeps four versions, + so never "Companion.app" by guess) and, only in the watchdog's first 10 minutes after it starts (that is, + after a login), opens that copy if no Companion is running. Never while an upgrade is going on.""" + WINDOW = 600 + + def __init__(self, engine, data_dir, started=None, running=companion_app_running, opener=None): + self.engine, self.data_dir = engine, data_dir + self.started = started if started is not None else time.time() + self.running, self.opener = running, opener or (lambda app: subprocess.run(["/usr/bin/open", "-g", app], timeout=30)) + self.opened_at = 0.0 + + def check(self, now=None): + now = now or time.time() + app = self.running() + known = self.engine.state.get("companion_app") + if app: + if app != known: + if known: + log.info("COMPANION now runs from %s (was %s)", app, known) + self.engine.state.set("companion_app", app) + return None + if not known or now - self.started > self.WINDOW or now - self.opened_at < 120 or not os.path.isdir(known): + return None + flag = os.path.join(self.data_dir or "", "companion-upgrading") + if self.data_dir and os.path.exists(flag) and now - os.path.getmtime(flag) < 1800: + return None + self.opened_at = now + log.info("COMPANION was not running after this Mac started: opening %s", known) + self.opener(known) + return known + + class Periodic(threading.Thread): def __init__(self, name, interval, fn, stop): super().__init__(name=name, daemon=True) @@ -3703,6 +3751,11 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None): t = Periodic("dante-newcomers", 60.0, lambda: watch_dante_newcomers(snap, cfg, c5, engine), stop) t.start() threads.append(t) + if (cfg.get("companion") or {}).get("host", "127.0.0.1") in ("127.0.0.1", "localhost") and sys.platform == "darwin": + keeper = CompanionKeeper(engine, data_dir) + t = Periodic("companion-keeper", 30.0, keeper.check, stop) + t.start() + threads.append(t) if cfg.get("ready_page") and not (cfg.get("ready") or {}).get("disabled"): import readiness poller = readiness.Poller(cfg, snap, stop, data_dir, is_live=lambda: engine.live_now(time.time())[0]) diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index 698596d..6936155 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -2581,3 +2581,38 @@ class TestSwitches(unittest.TestCase): errors = schema.validate(bad)[0] self.assertTrue(any("bypass.peers" in e for e in errors)) self.assertTrue(any("nowhere" in e for e in errors)) + + +class TestCompanionKeeper(unittest.TestCase): + """After a restart the panel opens the Companion that ran last, and only then (Southcoast, 2026-10-21).""" + + def setUp(self): + self.engine = sp.Engine(CONFIG, os.path.join(tempfile.mkdtemp(), "state.json")) + self.data = tempfile.mkdtemp() + self.app = tempfile.mkdtemp(suffix=".app") # stands in for /Applications/Companion 5.0.7.app + self.opened = [] + self.now = 1000.0 + self.running = self.app + + def keeper(self): + return sp.CompanionKeeper(self.engine, self.data, started=self.now, running=lambda: self.running, + opener=self.opened.append) + + def test_it_remembers_the_running_copy_and_opens_it_after_a_restart(self): + self.keeper().check(self.now) # Companion running: remembered + self.assertEqual(self.engine.state.get("companion_app"), self.app) + self.running = None # the Mac restarted without Companion + k = self.keeper() + self.assertEqual(k.check(self.now + 30), self.app) + self.assertEqual(k.check(self.now + 60), None) # not again straight away + self.assertEqual(self.opened, [self.app]) + + def test_it_never_acts_later_on_or_during_an_upgrade_or_without_knowing_the_copy(self): + k = self.keeper() + self.running = None + self.assertIsNone(k.check(self.now + 30)) # never seen one: no guessing + self.engine.state.set("companion_app", self.app) + self.assertIsNone(k.check(self.now + 700)) # someone quit it later on: left alone + open(os.path.join(self.data, "companion-upgrading"), "w").write("x") + self.assertIsNone(self.keeper().check(self.now + 30)) # an upgrade is going on + self.assertEqual(self.opened, [])