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
+16
View File
@@ -43,6 +43,22 @@ In any worktree-style or portless setup, never assume default ports.
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### Desktop macOS compositor watchdog
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. It self-recovers
after a few minutes, which is too long for a foreground app.
`setupDarwinCompositorWatchdog`
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
this. It polls the renderer for frame production every couple of seconds and,
after a sustained stall while the window is visible and unlocked, restarts the
GPU process so Chromium rebuilds the display link. The probe is skipped while
the screen is locked or the window is hidden or minimized, since a window
legitimately stops producing frames then.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
+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,