super-productivity/electron/gpu-startup-guard.ts
Johannes Millan cbf3eb15d4 fix(electron): add GPU startup guard for confined Linux packages
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
2026-04-20 18:35:31 +02:00

160 lines
5.9 KiB
TypeScript

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<MarkerContent>;
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);
}
}
};