mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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
This commit is contained in:
parent
9f447198bb
commit
cbf3eb15d4
3 changed files with 202 additions and 0 deletions
160
electron/gpu-startup-guard.ts
Normal file
160
electron/gpu-startup-guard.ts
Normal file
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue