From 4569a4fe71ace7717d1c10bd1aeef4eca20f3a2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:28:52 -0700 Subject: [PATCH] fix(window): defer the repaint size nudge and skip it on macOS 26 (#10253) The show/restore repaint jiggle mutated the window frame synchronously inside AppKit's window-state dispatch. On macOS 26 (Tahoe), windows are scene-backed and a frame update sent re-entrantly from scene-update handling can self-deadlock the main thread in FrontBoardServices (kernel-confirmed spindump: performAsyncAndWait on the callout queue the main thread was draining). - Defer the setSize nudge to a fresh event-loop turn so it never runs inside the show/restore dispatch. - On Darwin 25+ (macOS 26) skip the nudge entirely and rely on webContents.invalidate(); the nudge works around a pre-Tahoe black-surface compositor bug. --- src/main/window/createMainWindow.test.ts | 63 ++++++++++++++++++++- src/main/window/createMainWindow.ts | 24 ++++++-- src/main/window/macos-tahoe-release.test.ts | 19 +++++++ src/main/window/macos-tahoe-release.ts | 8 +++ 4 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 src/main/window/macos-tahoe-release.test.ts create mode 100644 src/main/window/macos-tahoe-release.ts diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 78e9089cd9a..8cb4c2007ce 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -11,7 +11,8 @@ const { notificationShowMock, powerMonitorOnMock, powerMonitorRemoveListenerMock, - isMock + isMock, + macosTahoeMock } = vi.hoisted(() => { const menuPopupMock = vi.fn() const notificationShowMock = vi.fn() @@ -27,7 +28,8 @@ const { notificationShowMock, powerMonitorOnMock: vi.fn(), powerMonitorRemoveListenerMock: vi.fn(), - isMock: { dev: false } + isMock: { dev: false }, + macosTahoeMock: { value: false } } }) @@ -49,6 +51,10 @@ vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) +vi.mock('./macos-tahoe-release', () => ({ + isMacosTahoeOrNewer: vi.fn(() => macosTahoeMock.value) +})) + vi.mock('../app-icon', () => ({ getAppIconPath: vi.fn(() => 'icon') })) @@ -90,6 +96,7 @@ describe('createMainWindow', () => { powerMonitorOnMock.mockReset() powerMonitorRemoveListenerMock.mockReset() isMock.dev = false + macosTahoeMock.value = false vi.mocked(ipcMain.on).mockReset() vi.mocked(ipcMain.removeListener).mockReset() vi.mocked(ipcMain.handle).mockReset() @@ -357,6 +364,10 @@ describe('createMainWindow', () => { windowHandlers.get('restore')?.[0]?.() expect(webContents.invalidate).toHaveBeenCalledTimes(2) + // Why: the size nudge must never run inside the show/restore dispatch itself. + expect(browserWindowInstance.setSize).not.toHaveBeenCalled() + + vi.advanceTimersByTime(0) expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(1, 1201, 800) expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(1) @@ -377,6 +388,54 @@ describe('createMainWindow', () => { expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(setSizeCalls) }) + it('repaints without the size nudge on macOS 26+ where re-entrant frame updates can deadlock AppKit', () => { + vi.useFakeTimers() + macosTahoeMock.value = true + const windowHandlers = new Map void)[]>() + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + isDestroyed: vi.fn(() => false), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + const handlers = windowHandlers.get(event) ?? [] + handlers.push(handler) + windowHandlers.set(event, handlers) + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + withPlatform('darwin', () => createMainWindow(null)) + + windowHandlers.get('show')?.[0]?.() + expect(webContents.invalidate).toHaveBeenCalledTimes(1) + + // Why: the delayed second repaint must also stay setSize-free on Tahoe. + vi.advanceTimersByTime(300) + expect(webContents.invalidate).toHaveBeenCalledTimes(2) + expect(browserWindowInstance.setSize).not.toHaveBeenCalled() + }) + it('supports all minus key variants for terminal zoom out', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 29f04620384..e18d7122f97 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -48,6 +48,7 @@ import { resolveWindowCloseAction } from './window-close-decision' import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' import { closeDashboardPopout } from './dashboard-popout-window' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' +import { isMacosTahoeOrNewer } from './macos-tahoe-release' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() @@ -62,15 +63,26 @@ function forceRepaint(window: BrowserWindow): void { if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) { return } + // Why: macOS 26 scene-backed windows can deadlock the main thread on re-entrant frame updates; invalidate alone recovers the compositor there. + if (isMacosTahoeOrNewer()) { + return + } activeRepaintJiggles.add(window) - const [width, height] = window.getSize() - window.setSize(width + 1, height) + // Why: show/restore fire from inside AppKit's window-state dispatch; mutating the frame there re-enters scene handling, so nudge on a fresh turn. setTimeout(() => { - if (!window.isDestroyed()) { - window.setSize(width, height) + if (window.isDestroyed()) { + activeRepaintJiggles.delete(window) + return } - activeRepaintJiggles.delete(window) - }, 32) + const [width, height] = window.getSize() + window.setSize(width + 1, height) + setTimeout(() => { + if (!window.isDestroyed()) { + window.setSize(width, height) + } + activeRepaintJiggles.delete(window) + }, 32) + }, 0) } function installMacosVisibilityRepaint(window: BrowserWindow): void { diff --git a/src/main/window/macos-tahoe-release.test.ts b/src/main/window/macos-tahoe-release.test.ts new file mode 100644 index 00000000000..78aa7b2dd58 --- /dev/null +++ b/src/main/window/macos-tahoe-release.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { isMacosTahoeOrNewer } from './macos-tahoe-release' + +describe('isMacosTahoeOrNewer', () => { + it('detects Darwin 25+ (macOS 26) as Tahoe or newer', () => { + expect(isMacosTahoeOrNewer('25.5.0')).toBe(true) + expect(isMacosTahoeOrNewer('26.0.0')).toBe(true) + }) + + it('treats older Darwin releases as pre-Tahoe', () => { + expect(isMacosTahoeOrNewer('24.6.0')).toBe(false) + expect(isMacosTahoeOrNewer('23.0.0')).toBe(false) + }) + + it('treats unparseable releases as pre-Tahoe', () => { + expect(isMacosTahoeOrNewer('')).toBe(false) + expect(isMacosTahoeOrNewer('unknown')).toBe(false) + }) +}) diff --git a/src/main/window/macos-tahoe-release.ts b/src/main/window/macos-tahoe-release.ts new file mode 100644 index 00000000000..51ae640c4ec --- /dev/null +++ b/src/main/window/macos-tahoe-release.ts @@ -0,0 +1,8 @@ +import os from 'node:os' + +// Why: Darwin 25.x = macOS 26 (Tahoe), where AppKit windows are scene-backed and +// re-entrant frame updates can self-deadlock the main thread in FrontBoardServices. +export function isMacosTahoeOrNewer(darwinRelease: string = os.release()): boolean { + const major = Number.parseInt(darwinRelease, 10) + return Number.isFinite(major) && major >= 25 +}