fix: prevent macOS desktop unlock freeze after display sleep (#745)

* Restart the GPU process to recover from macOS compositor freezes

macOS display sleep can leave Chromium's GPU-process display link stuck
on a stale display, so the compositor stops producing frames and the
window looks frozen — unresponsive to clicks and keys — even though the
renderer stays alive.

setupDarwinCompositorWatchdog polls the renderer for frame production
and, on a sustained stall while the window is visible and unlocked,
restarts the GPU process so Chromium rebuilds the display link. This
replaces setupDarwinPaintRefresh, whose invalidate/resize nudges did
not address the dead display link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Give compositor watchdog a module home

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Yurui Zhou
2026-05-19 10:41:55 +08:00
committed by GitHub
co-authored by Claude Opus 4.7 Mohamed Boudra
parent 5a1c7f266c
commit aaabadb04b
5 changed files with 212 additions and 55 deletions
+2 -2
View File
@@ -19,12 +19,12 @@ import {
getMainWindowChromeOptions,
getWindowBackgroundColor,
resolveSystemWindowTheme,
setupDarwinPaintRefresh,
setupWindowResizeEvents,
setupDefaultContextMenu,
setupDragDropPrevention,
buildStandardContextMenuItems,
} from "./window/window-manager.js";
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import {
registerNotificationHandlers,
@@ -398,7 +398,7 @@ async function createMainWindow(): Promise<void> {
app.dock?.setBadge(devWorktreeName);
}
setupDarwinPaintRefresh(mainWindow);
setupDarwinCompositorWatchdog(mainWindow);
setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { shouldRecoverFromFrameStall } from ".";
describe("compositor-watchdog", () => {
describe("shouldRecoverFromFrameStall", () => {
const recoverable = {
stalledChecks: 3,
recovering: false,
msSinceLastRecovery: 120_000,
consecutiveRecoveries: 0,
};
it("recovers once the stall threshold is reached", () => {
expect(shouldRecoverFromFrameStall(recoverable)).toBe(true);
});
it("waits until the stall threshold is reached", () => {
expect(shouldRecoverFromFrameStall({ ...recoverable, stalledChecks: 2 })).toBe(false);
});
it("does not recover while a recovery is already in progress", () => {
expect(shouldRecoverFromFrameStall({ ...recoverable, recovering: true })).toBe(false);
});
it("respects the cooldown between recoveries", () => {
expect(shouldRecoverFromFrameStall({ ...recoverable, msSinceLastRecovery: 30_000 })).toBe(
false,
);
});
it("stops recovering after the consecutive-recovery cap", () => {
expect(shouldRecoverFromFrameStall({ ...recoverable, consecutiveRecoveries: 3 })).toBe(false);
});
});
});
@@ -0,0 +1,158 @@
import { app, type BrowserWindow, powerMonitor } from "electron";
// COMPAT(darwinCompositorWatchdog): added in v0.1.78, target removal after
// 2026-11-19. Workaround for Electron/Chromium macOS display-sleep compositor
// stalls; re-test when Electron/Chromium is upgraded.
// How often the main process probes the renderer for frame production.
const FRAME_PROBE_INTERVAL_MS = 2000;
// A probed frame must arrive within this window or the probe counts as stalled.
const FRAME_PROBE_DEADLINE_MS = 300;
// Consecutive stalled probes before the watchdog restarts the GPU process (~6 s).
const FRAME_STALL_CHECKS_TO_RECOVER = 3;
// Minimum gap between GPU-process restarts.
const COMPOSITOR_RECOVERY_COOLDOWN_MS = 60_000;
// Grace period for Chromium to relaunch the GPU process before probing resumes.
const GPU_RELAUNCH_GRACE_MS = 5_000;
// Stop restarting the GPU process after this many tries without frames returning.
const MAX_CONSECUTIVE_RECOVERIES = 3;
// Resolves { producedFrame, visibilityState } for the renderer. The frame is
// requested with requestAnimationFrame; setTimeout (not vsync-driven) bounds the
// wait so the probe always resolves even when frame production has stopped.
const FRAME_PROBE_SOURCE = `new Promise((resolve) => {
let settled = false;
const finish = (producedFrame) => {
if (settled) return;
settled = true;
resolve({ producedFrame, visibilityState: document.visibilityState });
};
requestAnimationFrame(() => finish(true));
setTimeout(() => finish(false), ${FRAME_PROBE_DEADLINE_MS});
})`;
interface FrameStallState {
stalledChecks: number;
recovering: boolean;
msSinceLastRecovery: number;
consecutiveRecoveries: number;
}
export function shouldRecoverFromFrameStall(state: FrameStallState): boolean {
return (
state.stalledChecks >= FRAME_STALL_CHECKS_TO_RECOVER &&
!state.recovering &&
state.msSinceLastRecovery >= COMPOSITOR_RECOVERY_COOLDOWN_MS &&
state.consecutiveRecoveries < MAX_CONSECUTIVE_RECOVERIES
);
}
function findGpuProcessPid(): number | null {
for (const metric of app.getAppMetrics()) {
if (metric.type === "GPU") {
return metric.pid;
}
}
return null;
}
// macOS display sleep can leave Chromium's GPU-process display link (the vsync
// source that drives frame production) stuck on a stale display. The compositor
// then stops producing frames and the window looks frozen: unresponsive to
// clicks and keys even though the renderer and every process stay alive. This
// watchdog polls the renderer for frame production and, on a sustained stall,
// restarts the GPU process so Chromium rebuilds the display link.
export function setupDarwinCompositorWatchdog(win: BrowserWindow): void {
if (process.platform !== "darwin") {
return;
}
// Keep producing frames while occluded so the probe is not fooled by throttling.
win.webContents.setBackgroundThrottling(false);
let stalledChecks = 0;
let recovering = false;
let lastRecoveryAt = 0;
let consecutiveRecoveries = 0;
let screenLocked = false;
const recoverCompositor = async () => {
recovering = true;
lastRecoveryAt = Date.now();
consecutiveRecoveries += 1;
stalledChecks = 0;
const gpuPid = findGpuProcessPid();
console.warn(
`[compositor-watchdog] Desktop window stopped producing frames; restarting GPU process ` +
`(pid=${gpuPid ?? "unknown"}, attempt ${consecutiveRecoveries}) to recover`,
);
if (gpuPid !== null) {
try {
process.kill(gpuPid, "SIGKILL");
} catch (error) {
console.warn("[compositor-watchdog] Could not restart GPU process", error);
}
}
await new Promise((resolve) => setTimeout(resolve, GPU_RELAUNCH_GRACE_MS));
recovering = false;
};
const probeFrameProduction = async () => {
if (win.isDestroyed() || recovering) {
return;
}
// A freeze is only meaningful, and only distinguishable from a normal idle
// window, while the window is actually on screen. A locked screen, a
// minimized window, or a hidden one legitimately stops producing frames.
if (screenLocked || !win.isVisible() || win.isMinimized()) {
stalledChecks = 0;
return;
}
let result: { producedFrame?: unknown; visibilityState?: unknown } | null;
try {
result = await win.webContents.executeJavaScript(FRAME_PROBE_SOURCE);
} catch {
return;
}
if (!result || result.visibilityState !== "visible") {
stalledChecks = 0;
return;
}
if (result.producedFrame === true) {
stalledChecks = 0;
consecutiveRecoveries = 0;
return;
}
stalledChecks += 1;
if (
shouldRecoverFromFrameStall({
stalledChecks,
recovering,
msSinceLastRecovery: Date.now() - lastRecoveryAt,
consecutiveRecoveries,
})
) {
void recoverCompositor();
}
};
const probeTimer = setInterval(() => void probeFrameProduction(), FRAME_PROBE_INTERVAL_MS);
const handleScreenLocked = () => {
screenLocked = true;
stalledChecks = 0;
};
const handleScreenUnlocked = () => {
screenLocked = false;
stalledChecks = 0;
};
powerMonitor.on("lock-screen", handleScreenLocked);
powerMonitor.on("unlock-screen", handleScreenUnlocked);
win.once("closed", () => {
clearInterval(probeTimer);
powerMonitor.off("lock-screen", handleScreenLocked);
powerMonitor.off("unlock-screen", handleScreenUnlocked);
});
}
@@ -229,59 +229,6 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
});
}
function refreshChromiumSurface(win: BrowserWindow): void {
if (win.isDestroyed()) {
return;
}
win.webContents.invalidate();
if (win.isMaximized() || win.isFullScreen()) {
return;
}
const [width, height] = win.getSize();
win.setSize(width + 1, height);
setTimeout(() => {
if (!win.isDestroyed()) {
win.setSize(width, height);
}
}, 32);
}
export function setupDarwinPaintRefresh(win: BrowserWindow): void {
if (process.platform !== "darwin") {
return;
}
win.webContents.setBackgroundThrottling(false);
const requestSurfaceRefresh = () => {
if (!win.isDestroyed()) {
win.webContents.invalidate();
}
};
const handleChildProcessGone = (
_event: Electron.Event,
details: { type?: string; reason?: string },
) => {
if (details.type !== "GPU") {
return;
}
console.warn("[window] GPU process gone:", details.reason);
refreshChromiumSurface(win);
};
win.on("restore", requestSurfaceRefresh);
win.on("show", requestSurfaceRefresh);
app.on("child-process-gone", handleChildProcessGone);
win.once("closed", () => {
win.off("restore", requestSurfaceRefresh);
win.off("show", requestSurfaceRefresh);
app.off("child-process-gone", handleChildProcessGone);
});
}
export function buildStandardContextMenuItems(
contents: WebContents,
params: Electron.ContextMenuParams,