diff --git a/docs/development.md b/docs/development.md index b0f824396..861dbe782 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 5efd7a534..7fcc9a2f6 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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 { app.dock?.setBadge(devWorktreeName); } - setupDarwinPaintRefresh(mainWindow); + setupDarwinCompositorWatchdog(mainWindow); setupWindowResizeEvents(mainWindow); setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); diff --git a/packages/desktop/src/window/compositor-watchdog/index.test.ts b/packages/desktop/src/window/compositor-watchdog/index.test.ts new file mode 100644 index 000000000..3dc4b524d --- /dev/null +++ b/packages/desktop/src/window/compositor-watchdog/index.test.ts @@ -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); + }); + }); +}); diff --git a/packages/desktop/src/window/compositor-watchdog/index.ts b/packages/desktop/src/window/compositor-watchdog/index.ts new file mode 100644 index 000000000..654abde39 --- /dev/null +++ b/packages/desktop/src/window/compositor-watchdog/index.ts @@ -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); + }); +} diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index 176ef6c90..56697685d 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -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,