diff --git a/electron/crash-watchdog.js b/electron/crash-watchdog.js new file mode 100644 index 00000000..57b90b38 --- /dev/null +++ b/electron/crash-watchdog.js @@ -0,0 +1,98 @@ +// Mac-only crash watchdog. Spawned detached by main.js on startup (packaged +// builds only). Polls the parent PID; when the parent dies, decides whether +// to relaunch the .app bundle. +// +// FIVE GUARDS prevent false-positive relaunches. ALL must pass to relaunch: +// 1. Platform = darwin AND env vars populated. (Anything else: silent exit.) +// 2. Parent ran > MIN_UPTIME_MS before dying. Anything shorter = startup +// crash loop, refuse to keep spawning a broken binary. +// 3. No clean-quit lock present. main.js writes this lock in `before-quit` +// when the user intentionally Cmd+Q's so we know the exit was deliberate. +// 4. No updating.lock present. The auto-updater writes this around the swap +// so the parent dying mid-update doesn't get treated as a crash. +// 5. Fewer than MAX_RELAUNCHES in the last RELAUNCH_WINDOW_MS. Crash-loop +// cap so a chronically broken build doesn't hammer the user infinitely. +// +// On EVERY failure mode, the watchdog silently exits. The worst case is "app +// crashes and doesn't relaunch", which is exactly the current behavior with +// no watchdog — i.e., this can't make things worse than they are. + +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PARENT_PID = parseInt(process.env.OPENSWARM_PARENT_PID || '0', 10); +const APP_BUNDLE_PATH = process.env.OPENSWARM_APP_BUNDLE_PATH || ''; +const PARENT_START_TIME = parseInt(process.env.OPENSWARM_PARENT_START_TIME || '0', 10); + +if (process.platform !== 'darwin' || !PARENT_PID || !APP_BUNDLE_PATH || !PARENT_START_TIME) { + process.exit(0); +} + +const SUPPORT_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'openswarm'); +const UPDATING_LOCK = path.join(SUPPORT_DIR, 'updating.lock'); +const CLEAN_QUIT_LOCK = path.join(SUPPORT_DIR, 'clean-quit.lock'); +const RELAUNCH_LOG = path.join(SUPPORT_DIR, 'crash-watchdog-relaunches.log'); + +const MIN_UPTIME_MS = 30_000; +const POLL_INTERVAL_MS = 2_000; +const RELAUNCH_WINDOW_MS = 60 * 60 * 1000; +const MAX_RELAUNCHES = 3; + +function isAlive(pid) { + try { process.kill(pid, 0); return true; } catch (_) { return false; } +} + +function countRecentRelaunches() { + if (!fs.existsSync(RELAUNCH_LOG)) return 0; + try { + const lines = fs.readFileSync(RELAUNCH_LOG, 'utf-8').trim().split('\n').filter(Boolean); + const cutoff = Date.now() - RELAUNCH_WINDOW_MS; + return lines.filter((ln) => parseInt(ln, 10) > cutoff).length; + } catch (_) { return 0; } +} + +function recordRelaunch() { + try { + if (!fs.existsSync(SUPPORT_DIR)) fs.mkdirSync(SUPPORT_DIR, { recursive: true }); + fs.appendFileSync(RELAUNCH_LOG, `${Date.now()}\n`); + } catch (_) {} +} + +async function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +(async function watch() { + while (isAlive(PARENT_PID)) { + await sleep(POLL_INTERVAL_MS); + } + + // Parent died. Now check ALL five guards (platform already verified above). + + // Guard 2: parent ran long enough to rule out startup crash loop. + const uptime = Date.now() - PARENT_START_TIME; + if (uptime < MIN_UPTIME_MS) process.exit(0); + + // Guard 3: clean quit (Cmd+Q, intentional). Consume the lock so the next + // crash doesn't accidentally read a stale signal. + if (fs.existsSync(CLEAN_QUIT_LOCK)) { + try { fs.unlinkSync(CLEAN_QUIT_LOCK); } catch (_) {} + process.exit(0); + } + + // Guard 4: auto-updater is doing the swap. Parent dying is expected. + if (fs.existsSync(UPDATING_LOCK)) process.exit(0); + + // Guard 5: cap repeats in the window. + if (countRecentRelaunches() >= MAX_RELAUNCHES) process.exit(0); + + // All guards passed: relaunch. `open -n` opens a fresh instance even if the + // app is registered, which it always will be (LaunchServices remembers). + recordRelaunch(); + try { + spawn('open', ['-n', APP_BUNDLE_PATH], { detached: true, stdio: 'ignore' }).unref(); + } catch (_) {} + process.exit(0); +})(); diff --git a/electron/main.js b/electron/main.js index d14a6aad..7a4c93f7 100644 --- a/electron/main.js +++ b/electron/main.js @@ -338,7 +338,16 @@ app.on('open-url', (event, url) => { if (mainWindow) mainWindow.focus(); }); -app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling'); +// Disabled Chromium features. Mac gets one extra: MacWebContentsOcclusion is +// Chromium's window-occlusion tracker that subscribes to NSEvent / NSApplicationSceneWorkspace +// events on the main thread — exactly the code path the user-reported macOS 26.5 + Electron 42 +// NSEvent null-deref crash lives in. Disabling it routes around the subscription. Conservative: +// the only cost is slightly higher CPU when the window is fully hidden behind other apps +// (Chromium keeps painting invisible frames instead of pausing), zero impact when window is +// foreground. If this doesn't help, removing the flag is a one-line revert with no UX trace. +const _disabledFeatures = ['HardwareMediaKeyHandling']; +if (process.platform === 'darwin') _disabledFeatures.push('MacWebContentsOcclusion'); +app.commandLine.appendSwitch('disable-features', _disabledFeatures.join(',')); // disableHardwareAcceleration() was tried as a fallback but did not stop the 0xC0000005 crashes, confirming the segfault is not GPU-side. Dev mode (http origin) never crashed, packaged (file:// origin) always crashed, so the embedded localhost HTTP server (see startFrontendServer below) is the real fix and we keep GPU acceleration on. app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); @@ -444,6 +453,51 @@ async function startFrontendServer() { const isPackaged = app.isPackaged; const isDev = process.env.ELECTRON_DEV === '1'; + +// Mac-only crash watchdog. Targets the macOS 26.5 + Electron 42 NSEvent +// null-deref users have reported (wake-from-sleep mostly). When the parent +// dies unexpectedly, the watchdog calls `open -n /Applications/OpenSwarm.app` +// to bring the user back in ~2s. Five guards in crash-watchdog.js prevent +// false-positive relaunches (intentional Cmd+Q, auto-updater swap, startup +// crash loop, repeat cap). Packaged builds only; never runs in dev. +const CRASH_WATCHDOG_SUPPORT_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'openswarm'); +const CRASH_WATCHDOG_CLEAN_QUIT_LOCK = path.join(CRASH_WATCHDOG_SUPPORT_DIR, 'clean-quit.lock'); + +function spawnCrashWatchdog() { + if (process.platform !== 'darwin') return; + if (!isPackaged) return; + try { + const watchdogScript = path.join(__dirname, 'crash-watchdog.js'); + if (!fs.existsSync(watchdogScript)) return; + // .../OpenSwarm.app/Contents/Resources/ -> .../OpenSwarm.app + const appBundle = path.join(process.resourcesPath, '..', '..'); + const { spawn: _spawn } = require('child_process'); + const child = _spawn(process.execPath, [watchdogScript], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + OPENSWARM_PARENT_PID: String(process.pid), + OPENSWARM_APP_BUNDLE_PATH: appBundle, + OPENSWARM_PARENT_START_TIME: String(Date.now()), + }, + }); + child.unref(); + } catch (e) { + console.warn('[crash-watchdog] spawn failed:', e && e.message); + } +} + +function writeCleanQuitLock() { + if (process.platform !== 'darwin') return; + try { + if (!fs.existsSync(CRASH_WATCHDOG_SUPPORT_DIR)) fs.mkdirSync(CRASH_WATCHDOG_SUPPORT_DIR, { recursive: true }); + fs.writeFileSync(CRASH_WATCHDOG_CLEAN_QUIT_LOCK, ''); + } catch (_) {} +} + +app.on('before-quit', writeCleanQuitLock); const iconPath = process.platform === 'win32' ? path.join(__dirname, 'build', 'icon.ico') : path.join(__dirname, 'build', 'icon.png'); @@ -1444,6 +1498,11 @@ function killBackend() { } app.whenReady().then(async () => { + // Spawn the Mac crash watchdog. Detached process; if it fails to spawn the + // app continues normally (silent fail by design). Guards inside the + // watchdog itself prevent false-positive relaunches. + spawnCrashWatchdog(); + // Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's // in argv; macOS fires open-url AFTER whenReady which we handle above) // route through forwardDeepLinkToRenderer so the URL gets stashed under