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.
|
`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.
|
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
|
### Daemon logs
|
||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import {
|
|||||||
getMainWindowChromeOptions,
|
getMainWindowChromeOptions,
|
||||||
getWindowBackgroundColor,
|
getWindowBackgroundColor,
|
||||||
resolveSystemWindowTheme,
|
resolveSystemWindowTheme,
|
||||||
setupDarwinPaintRefresh,
|
|
||||||
setupWindowResizeEvents,
|
setupWindowResizeEvents,
|
||||||
setupDefaultContextMenu,
|
setupDefaultContextMenu,
|
||||||
setupDragDropPrevention,
|
setupDragDropPrevention,
|
||||||
buildStandardContextMenuItems,
|
buildStandardContextMenuItems,
|
||||||
} from "./window/window-manager.js";
|
} from "./window/window-manager.js";
|
||||||
|
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||||
import {
|
import {
|
||||||
registerNotificationHandlers,
|
registerNotificationHandlers,
|
||||||
@@ -398,7 +398,7 @@ async function createMainWindow(): Promise<void> {
|
|||||||
app.dock?.setBadge(devWorktreeName);
|
app.dock?.setBadge(devWorktreeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupDarwinPaintRefresh(mainWindow);
|
setupDarwinCompositorWatchdog(mainWindow);
|
||||||
setupWindowResizeEvents(mainWindow);
|
setupWindowResizeEvents(mainWindow);
|
||||||
setupDefaultContextMenu(mainWindow);
|
setupDefaultContextMenu(mainWindow);
|
||||||
setupDragDropPrevention(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(
|
export function buildStandardContextMenuItems(
|
||||||
contents: WebContents,
|
contents: WebContents,
|
||||||
params: Electron.ContextMenuParams,
|
params: Electron.ContextMenuParams,
|
||||||
|
|||||||
Reference in New Issue
Block a user