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:
co-authored by
Claude Opus 4.7
Mohamed Boudra
parent
5a1c7f266c
commit
aaabadb04b
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user