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 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 14:26:18 -04:00
co-authored by Claude Opus 5.5
parent 268ac21715
commit 5db1d93732
3 changed files with 95 additions and 0 deletions
+7
View File
@@ -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)
+53
View File
@@ -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])
+35
View File
@@ -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, [])