mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 14:32:22 +02:00
[eric] mac: chromium occlusion-feature flag dodge plus a watchdog that quietly relaunches on crash (5 guards so it never false-fires)
This commit is contained in:
@@ -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);
|
||||
})();
|
||||
+60
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user