mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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.
This commit is contained in:
@@ -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<string, ((...args: any[]) => 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<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
|
||||
@@ -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<BrowserWindow>()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user