From cbf3eb15d45a7de01cc8fdcdedc71a19e2fd440f Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 20 Apr 2026 18:28:11 +0200 Subject: [PATCH] fix(electron): add GPU startup guard for confined Linux packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth against GPU init failures on Snap/Flatpak Linux where the main process stays alive but the GPU process crashes at init and the window never renders. Field data in #7270 (two post-v18.2.4 reports) shows this happens on Ubuntu 24.04+/25.10 regardless of GPU vendor — the driver is core22 Mesa/libgbm drifted from the host Mesa. See §12–§17 in docs/research/snap-wayland-gpu-fix-research.md. Mechanism (electron/gpu-startup-guard.ts): - Content-based crash marker in userData with {ts, electronVersion}. Written before app.whenReady() on confined Linux; cleared via IPC.APP_READY after Angular boot — not ready-to-show, which fires on blank/broken renderers too. - Previous-crash detection: marker present AND recent (<5 min) AND matching Electron version. Staleness bound + version gating drop systemd-SIGKILL-mid-boot and post-upgrade-residue false-negatives. - Env overrides SP_DISABLE_GPU=1 / SP_ENABLE_GPU=1 work on all platforms; auto-detection is Linux+Snap/Flatpak-only. - Non-ENOENT fs errors logged at warn — a swallowed write-fail previously meant the guard could re-enter the loop with no diagnostic trail; a swallowed unlink-fail meant a successful boot could get permanently stuck in crash-recovery. Fallback flag bundle (start-app.ts): --disable-gpu --disable-software-rasterizer --ozone-platform=x11 The pair matches Chromium's GPU integration tests' "no GPU process" invariant; DisplayCompositor handles 2D in the browser process without spawning a GPU child. app.disableHardwareAcceleration() alone does NOT — verified against electron/electron#17180/#20702. The extra --ozone-platform=x11 closes the Chromium 140+ browser-side Wayland/libgbm-dlopen gap on Flatpak (redundant with the existing Snap X11 widening branch; last flag wins). Novelty: R3 survey of VS Code, Slack, Insomnia, LosslessCut, Obsidian flatpak, Firefox snap, and Canonical's gpu-2404-wrapper found no peer Electron-snap implementing an equivalent reactive crash-detection + auto-fallback. Prevailing patterns are manual --ozone-platform=x11, proactive env-sniff, or do-nothing. Review: §14–§15 multi-agent verification on the original PR #7273; re-reviewed via 7-agent multi-review (6 Claude focus-agents + Codex CLI) + 5 research agents here (§17). Live-tested with SP_DISABLE_GPU=1 on a KDE/X11 dev host — window rendered normally via DisplayCompositor. Refs: #7270 --- electron/gpu-startup-guard.ts | 160 ++++++++++++++++++++++++++++++++++ electron/main-window.ts | 6 ++ electron/start-app.ts | 36 ++++++++ 3 files changed, 202 insertions(+) create mode 100644 electron/gpu-startup-guard.ts diff --git a/electron/gpu-startup-guard.ts b/electron/gpu-startup-guard.ts new file mode 100644 index 0000000000..ae18fdb57a --- /dev/null +++ b/electron/gpu-startup-guard.ts @@ -0,0 +1,160 @@ +import * as fs from 'fs'; +import { join } from 'path'; +import { warn } from 'electron-log/main'; + +// Content-based crash marker: a JSON-encoded `{ ts, electronVersion }` is +// written whenever a launch is in flight and removed on IPC.APP_READY. +// A leftover file therefore means the previous launch never finished +// booting, which on Snap/Flatpak is overwhelmingly a GPU-process init +// failure (Mesa/libgbm ABI drift against the core22 snap runtime, missing +// DRI nodes under confinement). The marker is ignored if older than +// STALE_THRESHOLD_MS (suspend/SIGKILL false-negatives) or if the Electron +// version has changed since it was written (stale marker from a previous +// build should not trigger recovery). +const MARKER_FILE = '.gpu-launch-incomplete'; +const STALE_THRESHOLD_MS = 5 * 60 * 1000; +// Leftovers from earlier iterations of this guard. Cleaned up on startup +// so users who ran an intermediate build don't carry stale state. +const LEGACY_MARKER_FILES = ['.gpu-startup-state', '.gpu-startup-state.json']; + +const isTruthyEnv = (v: string | undefined): boolean => + !!v && /^(1|true|yes|on)$/i.test(v.trim()); + +interface MarkerContent { + ts: number; + electronVersion: string; +} + +const readMarker = (path: string): MarkerContent | null => { + let raw: string; + try { + raw = fs.readFileSync(path, 'utf8'); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + warn('gpu-startup-guard: failed to read marker', e); + } + return null; + } + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed?.ts !== 'number' || typeof parsed?.electronVersion !== 'string') { + return null; + } + return { ts: parsed.ts, electronVersion: parsed.electronVersion }; + } catch { + return null; + } +}; + +let markerPath: string | null = null; + +export interface GpuGuardDecision { + disableGpu: boolean; + reason: 'env' | 'crash-recovery' | null; + markerPath: string | null; +} + +/** + * Must run after `app.setPath('userData', ...)` and before + * `app.whenReady()`. Only auto-detects under Snap/Flatpak confinement on + * Linux — the failure mode this guards against is specific to confined + * packages with drifting Mesa stacks. `SP_DISABLE_GPU` / `SP_ENABLE_GPU` + * env vars work everywhere. + */ +export const evaluateGpuStartupGuard = (userDataPath: string): GpuGuardDecision => { + const isConfinedLinux = + process.platform === 'linux' && (!!process.env.SNAP || !!process.env.FLATPAK_ID); + + // Set the module-level marker path unconditionally on confined Linux so + // `markGpuStartupSuccess` can clean up a stale marker even when this + // launch took an env-var override path and never checked it. + if (isConfinedLinux) { + markerPath = join(userDataPath, MARKER_FILE); + } + + if (isTruthyEnv(process.env.SP_ENABLE_GPU)) { + return { disableGpu: false, reason: null, markerPath }; + } + if (isTruthyEnv(process.env.SP_DISABLE_GPU)) { + return { disableGpu: true, reason: 'env', markerPath }; + } + + if (!isConfinedLinux) { + // Reset module-level state so a second call (tests, reinit) doesn't + // leak a previous confined-launch's path into markGpuStartupSuccess(). + markerPath = null; + return { disableGpu: false, reason: null, markerPath: null }; + } + + // Narrow markerPath for fs calls below — it was set on the + // isConfinedLinux branch above, but TS can't track module-let + // assignment across the early returns. + const activeMarker: string = markerPath as string; + + for (const old of LEGACY_MARKER_FILES) { + try { + fs.unlinkSync(join(userDataPath, old)); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + warn('gpu-startup-guard: failed to remove legacy marker', e); + } + } + } + + // A leftover marker only counts as a previous crash if it's recent AND + // matches the current Electron version. Older / mismatched markers are + // treated as "clean" — a crash from a different Electron or from more + // than STALE_THRESHOLD_MS ago is almost certainly a systemd SIGKILL, + // suspend-mid-boot, or post-upgrade residue, not a GPU init loop. + const marker = readMarker(activeMarker); + const previousCrash = + marker !== null && + Date.now() - marker.ts < STALE_THRESHOLD_MS && + marker.electronVersion === process.versions.electron; + + // mkdirSync is load-bearing on first-ever install: Electron's + // `app.setPath('userData', ...)` does NOT create the directory, so + // $SNAP_USER_COMMON/.config/superproductivity may not exist yet on the + // first launch of a fresh Snap install. Without this, writeFileSync + // would fail silently and the guard would never write a marker. + try { + fs.mkdirSync(userDataPath, { recursive: true }); + const content: MarkerContent = { + ts: Date.now(), + electronVersion: process.versions.electron, + }; + fs.writeFileSync(activeMarker, JSON.stringify(content)); + } catch (e) { + warn('gpu-startup-guard: failed to write marker', e); + } + + return { + disableGpu: previousCrash, + reason: previousCrash ? 'crash-recovery' : null, + markerPath: activeMarker, + }; +}; + +/** + * Clears the crash marker. Must be preceded by `evaluateGpuStartupGuard` + * in the same process — relies on the module-level `markerPath` that + * `evaluateGpuStartupGuard` sets. No-op otherwise. + * + * Intended to be called from the `IPC.APP_READY` handler (after Angular + * boot), not from `ready-to-show`: a blank/broken renderer can still + * paint a first frame, and clearing the marker on that signal would + * defeat the crash-recovery path. + */ +export const markGpuStartupSuccess = (): void => { + if (!markerPath) return; + try { + fs.unlinkSync(markerPath); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + // Non-ENOENT failure means the marker is still there. Next launch + // will unnecessarily disable GPU — log so the cause is diagnosable. + warn('gpu-startup-guard: failed to clear marker', e); + } + } +}; diff --git a/electron/main-window.ts b/electron/main-window.ts index 7323cba23d..7c2d48ad8c 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -26,6 +26,7 @@ import { ensureIndicator } from './indicator'; import { getIsMinimizeToTray, getIsQuiting, setIsQuiting } from './shared-state'; import { loadSimpleStoreAll } from './simple-store'; import { SimpleStoreKey } from './shared-with-frontend/simple-store.const'; +import { markGpuStartupSuccess } from './gpu-startup-guard'; let mainWin: BrowserWindow; @@ -277,6 +278,11 @@ export const createWindow = async ({ // listen for app ready ipcMain.on(IPC.APP_READY, () => { mainWinModule.isAppReady = true; + // Signal the GPU startup guard that the full boot chain completed + // (including Angular init) — not just that the compositor painted a + // frame. This avoids clearing the crash counter on blank/broken + // renderers that still fire `ready-to-show`. + markGpuStartupSuccess(); }); // Register F11 key handler for fullscreen toggle diff --git a/electron/start-app.ts b/electron/start-app.ts index af936c8db0..bbc9d58b8d 100644 --- a/electron/start-app.ts +++ b/electron/start-app.ts @@ -22,6 +22,7 @@ import { } from './protocol-handler'; import { getIsQuiting, setIsQuiting, setIsLocked } from './shared-state'; import { clearStaleLevelDbLocks } from './clear-stale-idb-locks'; +import { evaluateGpuStartupGuard } from './gpu-startup-guard'; import * as fs from 'fs'; const ICONS_FOLDER = __dirname + '/assets/icons/'; @@ -151,6 +152,41 @@ export const startApp = (): void => { app.setAppLogsPath(); } + // Defense-in-depth against GPU init failures on confined Linux packages + // (Snap Mesa ABI drift, missing DRI nodes under Flatpak, etc.) where the + // main process stays alive but the GPU process crashes at init and the + // window never renders. `--disable-gpu` avoids the hardware Mesa DRI + // driver load path — which is the ABI-drift source on confined Snap. + // Note: `--disable-gpu` does NOT guarantee "no GPU process" on Linux + // (Chromium may still run a GPU process in SwiftShader or + // DisplayCompositor mode), but those modes don't dlopen Mesa DRI + // drivers, which is what matters for this bug. `--disable-software- + // rasterizer` is added as belt-and-braces; the combined pair is what + // Chromium's own GPU integration tests treat as "no GPU process." + // `app.disableHardwareAcceleration()` only disables compositor accel + // and leaves the failing GPU-process-init path active. + // + // `--ozone-platform=x11` is also stacked: on Chromium 140+/Electron 38+ + // the Wayland auto-detection can dlopen libgbm in browser-side init + // before the GPU-process gate, so the flag pair alone is a false + // negative on Flatpak+Wayland hosts. On Snap this is redundant with + // the X11 widening block above, but appending twice is harmless (last + // value wins in Chromium argv parsing). + // + // IMPORTANT: must stay after every `app.setPath('userData', ...)` call + // above — the marker lives in userData. + const gpuDecision = evaluateGpuStartupGuard(app.getPath('userData')); + if (gpuDecision.disableGpu) { + app.commandLine.appendSwitch('disable-gpu'); + app.commandLine.appendSwitch('disable-software-rasterizer'); + app.commandLine.appendSwitch('ozone-platform', 'x11'); + log( + `Disabling GPU acceleration (reason: ${gpuDecision.reason}). ` + + `Set SP_ENABLE_GPU=1 to force-enable on the next launch` + + (gpuDecision.markerPath ? ` or delete ${gpuDecision.markerPath}.` : '.'), + ); + } + initDebug({ showDevTools: isShowDevTools }, IS_DEV); // NOTE: opening the folder crashes the mas build