← back to Desktop Dotbar

.claude/worktrees/dotbar-keepalive

153 lines

commit 0a572ef4b9a857fd60b8ff7a8a3906ed1bd21042
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 26 10:45:56 2026 -0700

    dotbar: keep-alive watchdog + hardened launchd plist — bar always shows (Steve 2026-09-26)
    
    - keepAliveTick every 5s: server /health dead 3x -> exit(1) for launchd relaunch; missing/stale
      strips -> rebuild; hidden strip -> showInactive; crashed renderer -> reload
    - render-process-gone / unresponsive / did-fail-load handlers reload the strip in place
    - createWindows failure now exits(1) instead of idling with zero windows
    - plist: ProcessType Interactive, LimitLoadToSessionType Aqua, ThrottleInterval 5 (committed copy)
    - test/keepalive.test.js incl. negative cases (45/45 pass)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01JpJCWWtvGsxiNt5v4Do7EV

diff --git a/electron-main.js b/electron-main.js
index b9289f9..3d45d5a 100644
--- a/electron-main.js
+++ b/electron-main.js
@@ -339,6 +339,14 @@ function createBarFor(display) {
   win.webContents.on('did-finish-load', () => { raise(win); pushBarH(win); pushCfg(win); applyBoundsFor(win); dbg('did-finish-load'); });
   win.webContents.on('did-fail-load', (_e, code, desc) => dbg('FAIL:' + code + ':' + desc));
   win.on('moved', () => { onWinMoved(win); dbg('moved'); });   // user dragged the bar -> capture free position
+  // Keep-alive: a dead/hung renderer (GPU exits are common in bar.log) leaves a live process with a blank
+  // strip, which launchd KeepAlive can't see — reload it in place.
+  win.webContents.on('render-process-gone', (_e, d) => {
+    console.log('DOTBAR: renderer gone', d && d.reason, '— reloading');
+    setTimeout(() => { try { if (!win.isDestroyed()) win.webContents.reload(); } catch {} }, 500);
+  });
+  win.on('unresponsive', () => { console.log('DOTBAR: strip unresponsive — reloading'); try { win.webContents.forcefullyCrashRenderer(); win.webContents.reload(); } catch {} });
+  win.webContents.on('did-fail-load', () => setTimeout(() => { try { if (!win.isDestroyed()) win.loadURL(`http://127.0.0.1:${PORT}/`); } catch {} }, 3000));
   wins.push(win);
   return win;
 }
@@ -351,11 +359,48 @@ function rebuildBars() {
   for (const d of screen.getAllDisplays()) createBarFor(d);
 }
 
+// ── Keep-alive watchdog (Steve 2026-09-26: "nav bar needs to always show up and keep alive") ──
+// launchd KeepAlive only restarts us when the PROCESS exits. These cover the cases where the process stays
+// up but nothing useful is on screen: server died, a strip got destroyed/hidden, a renderer crashed.
+// Anything we can't heal in place → exit(1) so launchd relaunches start-bar.command (server + bar, fresh).
+const KEEPALIVE_MS = 5000, SERVER_MISS_LIMIT = 3;   // ~15s of a dead /health before a full relaunch
+let serverMisses = 0, tickBusy = false;
+function exitForRelaunch(why) {
+  console.log('DOTBAR: relaunching via launchd —', why);
+  try { flushCfg(); } catch {}
+  app.exit(1);
+}
+function keepAliveHeal(liveWins, displayIds) {   // pure: what should the watchdog do? (unit-tested)
+  if (liveWins.length !== displayIds.length) return 'rebuild';
+  const have = new Set(liveWins.map(w => String(w.displayId)));
+  if (displayIds.some(id => !have.has(String(id)))) return 'rebuild';
+  return 'ok';
+}
+async function keepAliveTick() {
+  if (tickBusy) return; tickBusy = true;
+  try {
+    if (await alive(PORT)) serverMisses = 0;
+    else if (++serverMisses >= SERVER_MISS_LIMIT) return exitForRelaunch(`server /health down ${serverMisses}x on :${PORT}`);
+    const live = wins.filter(w => !w.isDestroyed());
+    const ids = screen.getAllDisplays().map(d => d.id);
+    if (keepAliveHeal(live.map(w => ({ displayId: w._displayId })), ids) === 'rebuild') {
+      console.log(`DOTBAR: ${live.length} strip(s) for ${ids.length} display(s) — rebuilding`);
+      return rebuildBars();
+    }
+    for (const w of live) {
+      if (!w.isVisible()) { console.log('DOTBAR: strip hidden — showing'); w.showInactive(); }
+      if (w.webContents.isCrashed()) { console.log('DOTBAR: strip crashed — reloading'); w.webContents.reload(); }
+    }
+  } catch (e) { console.log('DOTBAR: keepAlive tick error', e && e.stack || e); }
+  finally { tickBusy = false; }
+}
+
 async function createWindows() {
   PORT = await ensureServer();
   for (const d of screen.getAllDisplays()) createBarFor(d);
   setInterval(() => wins.forEach(raise), 2000);   // re-assert top above other apps, every strip
   setInterval(pollAutohide, POLL_MS);             // auto-hide reveal/conceal
+  setInterval(keepAliveTick, KEEPALIVE_MS);       // the bar must ALWAYS be up (Steve, 2026-09-26)
   // React to monitor changes (a display added/removed, or geometry changed e.g. arrangement swap).
   screen.on('display-added', rebuildBars);
   screen.on('display-removed', rebuildBars);
@@ -387,9 +432,9 @@ if (process.versions.electron) {                 // run the app under Electron;
   app.on('before-quit', () => { flushCfg(); if (serverProc) try { serverProc.kill(); } catch {} });
   app.whenReady().then(() => {
     if (app.dock) app.dock.hide();            // accessory app: panels float above active apps
-    createWindows().catch(e => console.log('DOTBAR: createWindows ERROR', e && e.stack || e));
+    createWindows().catch(e => { console.log('DOTBAR: createWindows ERROR', e && e.stack || e); exitForRelaunch('createWindows failed'); });
   });
   process.on('uncaughtException', e => console.log('DOTBAR: uncaught', e && e.stack || e));
 }
 
-module.exports = { cfg, floatingPosFor, clampPosToDisplay, writeCfg, flushCfg, CFG_FILE };
+module.exports = { cfg, keepAliveHeal, floatingPosFor, clampPosToDisplay, writeCfg, flushCfg, CFG_FILE };
diff --git a/launchd/com.steve.desktop-dotbar.plist b/launchd/com.steve.desktop-dotbar.plist
new file mode 100644
index 0000000..72805dd
--- /dev/null
+++ b/launchd/com.steve.desktop-dotbar.plist
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<!-- Installed copy lives at ~/Library/LaunchAgents/com.steve.desktop-dotbar.plist.
+     Always-on (Steve 2026-09-26): RunAtLoad = launch at every login; KeepAlive = relaunch whenever it exits;
+     the in-app keepAliveTick exits(1) on anything it can't heal in place so launchd brings it back fresh.
+     Reinstall after edits: launchctl bootout gui/$(id -u)/com.steve.desktop-dotbar; launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.desktop-dotbar.plist -->
+<plist version="1.0">
+<dict>
+  <key>Label</key><string>com.steve.desktop-dotbar</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>/Users/macstudio3/Projects/desktop-dotbar/start-bar.command</string>
+  </array>
+  <key>RunAtLoad</key><true/>
+  <key>KeepAlive</key><true/>
+  <key>ThrottleInterval</key><integer>5</integer>
+  <key>ProcessType</key><string>Interactive</string>
+  <key>LimitLoadToSessionType</key><string>Aqua</string>
+  <key>StandardOutPath</key><string>/Users/macstudio3/Projects/desktop-dotbar/launchagent.log</string>
+  <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/desktop-dotbar/launchagent.log</string>
+</dict>
+</plist>
diff --git a/test/keepalive.test.js b/test/keepalive.test.js
new file mode 100644
index 0000000..a1cd45d
--- /dev/null
+++ b/test/keepalive.test.js
@@ -0,0 +1,21 @@
+'use strict';
+// Keep-alive watchdog (Steve 2026-09-26): one live strip per connected display, else rebuild.
+process.env.DOTBAR_TEST_CFG = '.tmp-keepalive-test.barcfg';
+const path = require('node:path'), fs = require('node:fs'), test = require('node:test'), assert = require('node:assert');
+const dm = require('../electron-main.js');
+test.after(() => { try { dm.flushCfg(); } catch {} try { fs.unlinkSync(path.join(__dirname, '..', process.env.DOTBAR_TEST_CFG)); } catch {} });
+const W = (...ids) => ids.map(displayId => ({ displayId }));
+
+test('healthy: one strip per display -> ok', () => {
+  assert.equal(dm.keepAliveHeal(W(1, 2), [1, 2]), 'ok');
+  assert.equal(dm.keepAliveHeal(W('5'), [5]), 'ok');          // display ids compared as strings
+});
+test('NEGATIVE: every strip gone (process alive, nothing on screen) -> rebuild', () => {
+  assert.equal(dm.keepAliveHeal([], [1]), 'rebuild');
+});
+test('NEGATIVE: a display lost its strip -> rebuild', () => {
+  assert.equal(dm.keepAliveHeal(W(1), [1, 2]), 'rebuild');
+});
+test('NEGATIVE: strip bound to a stale display id (macOS renumbered 5->6) -> rebuild', () => {
+  assert.equal(dm.keepAliveHeal(W(5), [6]), 'rebuild');
+});