From fed545d330ae5d3ef92c688a5c8eb902801e5b94 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:43:53 -0700 Subject: [PATCH] Stop wiping the shared WebGL glyph atlas on plain window refocus (#7604) Co-authored-by: Orca --- src/main/index.ts | 5 + src/main/system-resume-broadcast.test.ts | 64 +++++++++++++ src/main/system-resume-broadcast.ts | 39 ++++++++ src/preload/api-types.ts | 1 + src/preload/index.ts | 5 + .../terminal-visibility-resume.test.ts | 47 +++++++++- .../terminal-visibility-resume.ts | 19 +++- .../use-terminal-pane-global-effects.test.ts | 94 ++++++++++++++++++- .../use-terminal-window-wake-recovery.ts | 35 ++++++- .../src/lib/pane-manager/pane-manager.ts | 8 +- .../pane-manager/pane-reveal-repaint.test.ts | 30 +++++- .../lib/pane-manager/pane-reveal-repaint.ts | 44 +++++++-- src/renderer/src/web/web-preload-api.ts | 3 + 13 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 src/main/system-resume-broadcast.test.ts create mode 100644 src/main/system-resume-broadcast.ts diff --git a/src/main/index.ts b/src/main/index.ts index 251ea69e6..e9270e164 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -151,6 +151,7 @@ import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' +import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { getCrashBreadcrumbSnapshot, recordCoalescedCrashBreadcrumb, @@ -212,6 +213,7 @@ let starNag: StarNagService | null = null let agentAwakeService: AgentAwakeService | null = null let crashReports: CrashReportStore | null = null let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null +let unsubscribeSystemResumeBroadcast: (() => void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null @@ -1653,6 +1655,7 @@ app.whenReady().then(async () => { // Why: browser sessions are used by desktop webviews and runtime profile // commands, so initialize them at app startup instead of a renderer IPC path. initializeBrowserSessionsForApp() + unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() agentAwakeService = new AgentAwakeService() agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) // Why: disk-hydrated status rows are UI continuity only. The service starts @@ -2170,6 +2173,8 @@ app.whenReady().then(async () => { app.on('before-quit', () => { isQuitting = true + unsubscribeSystemResumeBroadcast?.() + unsubscribeSystemResumeBroadcast = null unsubscribeAgentAwakeStatusChanges?.() unsubscribeAgentAwakeStatusChanges = null agentAwakeService?.dispose() diff --git a/src/main/system-resume-broadcast.test.ts b/src/main/system-resume-broadcast.test.ts new file mode 100644 index 000000000..dc67ee27a --- /dev/null +++ b/src/main/system-resume-broadcast.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { registerSystemResumeBroadcast, SYSTEM_RESUMED_CHANNEL } from './system-resume-broadcast' + +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: vi.fn(() => []) }, + powerMonitor: { on: vi.fn(), off: vi.fn() } +})) + +type ResumeListener = () => void + +function createResumeSource() { + const state: { listener: ResumeListener | null } = { listener: null } + const source = { + on: vi.fn((_event: 'resume', callback: ResumeListener) => { + state.listener = callback + }), + off: vi.fn((_event: 'resume', _callback: ResumeListener) => { + state.listener = null + }) + } + return { source, fireResume: () => state.listener?.() } +} + +function createWindow(destroyed = false): { + isDestroyed: () => boolean + webContents: { send: ReturnType void>> } +} { + return { + isDestroyed: () => destroyed, + webContents: { send: vi.fn<(channel: string) => void>() } + } +} + +describe('registerSystemResumeBroadcast', () => { + it('broadcasts the resume channel to every live window', () => { + const { source, fireResume } = createResumeSource() + const liveWindow = createWindow() + const destroyedWindow = createWindow(true) + registerSystemResumeBroadcast({ + resumeSource: source, + getWindows: () => [liveWindow, destroyedWindow] + }) + + fireResume() + + expect(liveWindow.webContents.send).toHaveBeenCalledWith(SYSTEM_RESUMED_CHANNEL) + expect(destroyedWindow.webContents.send).not.toHaveBeenCalled() + }) + + it('stops broadcasting after unsubscribe', () => { + const { source, fireResume } = createResumeSource() + const window = createWindow() + const unsubscribe = registerSystemResumeBroadcast({ + resumeSource: source, + getWindows: () => [window] + }) + + unsubscribe() + fireResume() + + expect(source.off).toHaveBeenCalledTimes(1) + expect(window.webContents.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/system-resume-broadcast.ts b/src/main/system-resume-broadcast.ts new file mode 100644 index 000000000..990b8d2a6 --- /dev/null +++ b/src/main/system-resume-broadcast.ts @@ -0,0 +1,39 @@ +import { BrowserWindow, powerMonitor } from 'electron' + +export const SYSTEM_RESUMED_CHANNEL = 'system:resumed' + +type ResumeEventSource = { + on(event: 'resume', listener: () => void): unknown + off(event: 'resume', listener: () => void): unknown +} + +type ResumeBroadcastWindow = { + isDestroyed(): boolean + webContents: { send(channel: string): void } +} + +type SystemResumeBroadcastOptions = { + resumeSource?: ResumeEventSource + getWindows?: () => ResumeBroadcastWindow[] +} + +// Why: renderers cannot observe OS sleep/wake directly, and Linux has no +// window-occlusion tracking so visibilitychange never fires around suspend. +// Wake-sensitive renderer recovery needs this explicit resume signal. +export function registerSystemResumeBroadcast( + options: SystemResumeBroadcastOptions = {} +): () => void { + const resumeSource = options.resumeSource ?? powerMonitor + const getWindows = options.getWindows ?? (() => BrowserWindow.getAllWindows()) + const onResume = (): void => { + for (const window of getWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(SYSTEM_RESUMED_CHANNEL) + } + } + } + resumeSource.on('resume', onResume) + return () => { + resumeSource.off('resume', onResume) + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a0e6c9bea..184e0aea6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2679,6 +2679,7 @@ export type PreloadApi = { ) => () => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void + onSystemResumed: (callback: () => void) => () => void readClipboardText: (options?: ReadClipboardTextOptions) => Promise readSelectionClipboardText: (options?: ReadClipboardTextOptions) => Promise saveClipboardImageAsTempFile: (args?: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 727bee949..f0ef9cf7e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3454,6 +3454,11 @@ const api = { ipcRenderer.on('terminal:zoom', listener) return () => ipcRenderer.removeListener('terminal:zoom', listener) }, + onSystemResumed: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, readClipboardText: (options?: ReadClipboardTextOptions): Promise => ipcRenderer.invoke('clipboard:readText', options), readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index e8b59a3a4..b42304cb8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -31,13 +31,15 @@ type FakeManager = { getPanes: ReturnType resumeRendering: ReturnType scheduleRevealRepaint: ReturnType + scheduleRevealPresent: ReturnType } function createManager(order: string[] = []): FakeManager { return { getPanes: vi.fn(() => []), resumeRendering: vi.fn(() => order.push('resume-rendering')), - scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')) + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')), + scheduleRevealPresent: vi.fn(() => order.push('reveal-present')) } } @@ -79,13 +81,52 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(order).toEqual(['resume-rendering', 'reveal-repaint']) }) - it('schedules the repaint on window-wake recovery', () => { + it('schedules the atlas-clearing repaint on genuine wake recovery', () => { const manager = createManager() recoverVisibleTerminalWindowWake({ manager: manager as never as PaneManager, - isActive: false + isActive: false, + clearGlyphAtlases: true }) expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealPresent).not.toHaveBeenCalled() + }) + + it('clears shared glyph atlases only on genuine wake recovery', async () => { + const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked( + await import('@/lib/pane-manager/pane-manager-registry') + ) + const manager = createManager() + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: false, + clearGlyphAtlases: true + }) + + expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1) + }) + + it('keeps the warm glyph atlas on plain-refocus recovery', async () => { + // Deliberate reversal of the #6354 focus-clear: wiping the shared atlas on + // every refocus forces a mass re-rasterization that can hit xterm's atlas + // page-merge race (#4480) and garble streaming panes. Focus recovery must + // resume rendering and present WITHOUT the atlas-clearing reveal repaint — + // scheduleRevealRepaint clears each pane's (shared) atlas, so the refocus + // path must route to the atlas-preserving present instead. + const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked( + await import('@/lib/pane-manager/pane-manager-registry') + ) + const manager = createManager() + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: false, + clearGlyphAtlases: false + }) + + expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 0c821e5d0..d1a0cc9e2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -40,6 +40,7 @@ type HideTerminalVisibilityResult = { type RecoverVisibleTerminalWindowWakeArgs = { manager: PaneManager isActive: boolean + clearGlyphAtlases: boolean } export function resumeTerminalVisibility({ @@ -123,7 +124,8 @@ export function hideTerminalVisibility({ export function recoverVisibleTerminalWindowWake({ manager, - isActive + isActive, + clearGlyphAtlases }: RecoverVisibleTerminalWindowWakeArgs): void { // Why: macOS screensaver/display wake can leave xterm visible but with a // stale renderer/input surface; Orca's own hidden-state resume never runs. @@ -138,8 +140,19 @@ export function recoverVisibleTerminalWindowWake({ fitPanes(manager) } enforceTerminalViewportIntents(manager) - resetAndRefreshAllTerminalWebglAtlases() - manager.scheduleRevealRepaint() + if (clearGlyphAtlases) { + // Why: only a genuine wake may wipe the shared glyph atlas. The wipe makes + // every same-config pane re-rasterize at once, and xterm's atlas page-merge + // clear-model flag is consumed by one renderer (xterm.js #4480), so panes + // that lose that race paint garbled glyphs mid-stream. + resetAndRefreshAllTerminalWebglAtlases() + manager.scheduleRevealRepaint() + } else { + // Why: the reveal repaint clears each pane's texture atlas (a shared, + // same-config wipe), so a plain refocus must use the atlas-preserving + // present instead — otherwise it re-arms the same mid-stream garble race. + manager.scheduleRevealPresent() + } } function requestLightTabBacklogRecovery(manager: PaneManager): void { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 0ad8f80d1..f8355d8b3 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -131,6 +131,7 @@ function useMountForFileDrop( resumeRendering: ReturnType resetWebglTextureAtlases: ReturnType scheduleRevealRepaint: ReturnType + scheduleRevealPresent: ReturnType suspendRendering: ReturnType getActivePane: ReturnType } @@ -148,6 +149,7 @@ function useMountForFileDrop( resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -225,6 +227,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(() => order.push('resume')), resetWebglTextureAtlases: vi.fn(() => order.push('reset-atlas')), scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')), + scheduleRevealPresent: vi.fn(() => order.push('reveal-present')), refreshAllPanes: vi.fn(() => order.push('refresh')), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -313,6 +316,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -393,6 +397,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -455,6 +460,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -509,6 +515,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -587,6 +594,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } })) } @@ -619,6 +627,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null), @@ -672,18 +681,22 @@ describe('useTerminalPaneGlobalEffects', () => { expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA) }) - it('clears WebGL texture atlases when the active visible terminal regains focus', () => { + it('keeps the shared glyph atlas warm on plain window refocus', () => { const manager = { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } - // Why: focus recovery resets every registered manager (shared glyph - // atlas), so the fake manager observes the reset through the registry. + // Why: deliberate reversal of the #6354 focus-clear. A refocus atlas wipe + // forces every pane to re-rasterize at once, and xterm's page-merge + // clear-model flag is consumed by a single renderer (#4480), so panes that + // lose the race paint garbled glyphs while an agent streams. Focus must + // stay a WebGL-retry + pane-scoped repaint boundary only. registerManagerForReset(manager) beginHookRender() useTerminalPaneGlobalEffects({ @@ -711,9 +724,18 @@ describe('useTerminalPaneGlobalEffects', () => { throw new Error('expected focus listener') } manager.resetWebglTextureAtlases.mockClear() + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() + listener(new Event('focus')) + listener(new Event('focus')) listener(new Event('focus')) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + // Count proof: repeated refocus performs zero shared-atlas wipes. It routes + // to the atlas-preserving present (scheduleRevealPresent), never the + // atlas-clearing reveal repaint, which would clear each pane's shared atlas. + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(3) }) it('recovers visible terminal rendering and input when the window regains focus', () => { @@ -723,6 +745,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal })) @@ -757,6 +780,10 @@ describe('useTerminalPaneGlobalEffects', () => { manager.resumeRendering.mockClear() manager.resetWebglTextureAtlases.mockClear() manager.refreshAllPanes.mockClear() + // Clear the mount-time reveal spies so the assertions measure only the + // focus event, not the initial visibility resume. + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() mocks.fitAndFocusPanes.mockClear() mocks.flushTerminalOutput.mockClear() mocks.requestTerminalBacklogRecovery.mockClear() @@ -767,6 +794,59 @@ describe('useTerminalPaneGlobalEffects', () => { expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 64 * 1024 }) expect(manager.resumeRendering).toHaveBeenCalledTimes(1) expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + // Why: refocus recovery is atlas-preserving — no shared-atlas reset, no + // registry-wide repaint, and no atlas-clearing reveal repaint; the + // atlas-preserving present covers stale pixels. + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) + }) + + it('clears WebGL texture atlases when the OS resumes', () => { + const manager = { + getPanes: vi.fn(() => []), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), + refreshAllPanes: vi.fn(), + suspendRendering: vi.fn(), + getActivePane: vi.fn(() => null) + } + const captured: { onSystemResumed: (() => void) | null } = { onSystemResumed: null } + const unsubscribeSystemResumed = vi.fn() + ;( + window.api.ui as unknown as { onSystemResumed: (callback: () => void) => () => void } + ).onSystemResumed = vi.fn((callback: () => void) => { + captured.onSystemResumed = callback + return unsubscribeSystemResumed + }) + + registerManagerForReset(manager) + beginHookRender() + useTerminalPaneGlobalEffects({ + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: true, + isVisible: true, + isSyncFitEnabled: true, + paneCount: 0, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + toggleExpandPane: vi.fn() + }) + + expect(captured.onSystemResumed).toBeTypeOf('function') + manager.resetWebglTextureAtlases.mockClear() + manager.refreshAllPanes.mockClear() + captured.onSystemResumed?.() + + // Why: OS resume is a genuine wake — GPU state may be stale without a + // context-loss event, so the shared-atlas clear and full repaint still run. expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) }) @@ -788,6 +868,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -843,6 +924,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -889,6 +971,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -945,6 +1028,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -1061,6 +1145,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) @@ -1095,6 +1180,7 @@ describe('useTerminalPaneGlobalEffects', () => { resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), scheduleRevealRepaint: vi.fn(), + scheduleRevealPresent: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 8d07259f7..8c5da7ed9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -20,6 +20,7 @@ export function useTerminalWindowWakeRecovery({ return } let wakeRecoveryFrameId: number | null = null + let settledClearGlyphAtlases = false const cancelScheduledWakeRecovery = (): void => { if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') { wakeRecoveryFrameId = null @@ -28,9 +29,12 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } - const recoverVisibleWake = (): void => { + const recoverVisibleWake = (clearGlyphAtlases: boolean): void => { // Focus and visibility often fire together; keep one immediate recovery and one settled RAF pass. if (wakeRecoveryFrameId !== null) { + // Why: a pending settled pass may only upgrade in strength — a plain + // focus that lands after a genuine wake must not skip its atlas clear. + settledClearGlyphAtlases ||= clearGlyphAtlases return } const manager = managerRef.current @@ -39,39 +43,60 @@ export function useTerminalWindowWakeRecovery({ } recoverVisibleTerminalWindowWake({ manager, - isActive: isActiveRef.current + isActive: isActiveRef.current, + clearGlyphAtlases }) if (typeof requestAnimationFrame !== 'function') { return } + settledClearGlyphAtlases = clearGlyphAtlases wakeRecoveryFrameId = requestAnimationFrame(() => { wakeRecoveryFrameId = null + const clearGlyphAtlasesOnSettle = settledClearGlyphAtlases + settledClearGlyphAtlases = false const settledManager = managerRef.current if (!settledManager || !isVisibleRef.current) { return } recoverVisibleTerminalWindowWake({ manager: settledManager, - isActive: isActiveRef.current + isActive: isActiveRef.current, + clearGlyphAtlases: clearGlyphAtlasesOnSettle }) }) } - const onFocus = (): void => recoverVisibleWake() + // Why: plain refocus (alt-tab, devtools) is frequent and often lands while + // an agent streams; wiping the shared glyph atlas then provokes xterm's + // page-merge race and paints garbled glyphs. Focus recovery keeps the warm + // atlas: it only retries WebGL attach, refits, and repaints pane-scoped. + const onFocus = (): void => recoverVisibleWake(false) const onVisibilityChange = (): void => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') { - recoverVisibleWake() + recoverVisibleWake(true) + } + } + // Why: Linux has no window-occlusion tracking, so visibilitychange never + // fires around system suspend; the main process broadcasts OS resume. + const onSystemResumed = (): void => { + if (typeof document === 'undefined' || document.visibilityState === 'visible') { + recoverVisibleWake(true) } } window.addEventListener('focus', onFocus) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } + const unsubscribeSystemResumed = + typeof window.api?.ui?.onSystemResumed === 'function' + ? window.api.ui.onSystemResumed(onSystemResumed) + : null return () => { cancelScheduledWakeRecovery() window.removeEventListener('focus', onFocus) if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { document.removeEventListener('visibilitychange', onVisibilityChange) } + unsubscribeSystemResumed?.() } }, [isActiveRef, isVisible, isVisibleRef, managerRef]) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 77ad53988..f04d5d9c3 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -41,7 +41,7 @@ import { } from './pane-rendering-control' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' -import { schedulePaneRevealRepaint } from './pane-reveal-repaint' +import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' import { closeManagedPane, @@ -329,6 +329,12 @@ export class PaneManager { schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values())) } + scheduleRevealPresent(): void { + // Why: same destroy guard as scheduleRevealRepaint, but presents without + // clearing the shared glyph atlas — used by the plain-refocus recovery. + schedulePaneRevealPresent(() => (this.destroyed ? [] : this.panes.values())) + } + suspendRendering(): void { this.renderingSuspended = true suspendPaneRendering(this.panes.values()) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts index 564a57e98..4985ffe13 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' -import { schedulePaneRevealRepaint } from './pane-reveal-repaint' +import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint' import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' type FakeWebglAddon = { clearTextureAtlas: ReturnType } @@ -144,4 +144,32 @@ describe('schedulePaneRevealRepaint', () => { expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) vi.useRealTimers() }) + + describe('schedulePaneRevealPresent', () => { + it('presents the settled buffer without wiping the shared glyph atlas', () => { + // The plain-refocus path must NOT clear the atlas — the clear is a + // same-config shared wipe that re-arms the mid-stream page-merge race. + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + schedulePaneRevealPresent(() => [pane]) + + flushFrame() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + flushFrame() + expect(webglAddon.clearTextureAtlas).not.toHaveBeenCalled() + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('still retries a missing WebGL attach on the settled frame', () => { + const pane = createPane() + schedulePaneRevealPresent(() => [pane]) + + flushFrame() + flushFrame() + + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.refresh).toHaveBeenCalled() + }) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts index 48df07b9b..9fb283782 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -15,6 +15,21 @@ function scheduleSettledFrame(callback: () => void): void { }) } +function forEachPaneOnSettledFrame( + getPanes: () => Iterable, + visit: (pane: ManagedPaneInternal) => void +): void { + scheduleSettledFrame(() => { + for (const pane of getPanes()) { + try { + visit(pane) + } catch { + /* ignore — one pane's failure must not block repaint of the rest */ + } + } + }) +} + /** * Repaints a revealed tab's panes from their xterm buffers. * @@ -26,14 +41,27 @@ function scheduleSettledFrame(callback: () => void): void { * settled — forces a full rebuild from the buffer without any PTY resize. */ export function schedulePaneRevealRepaint(getPanes: () => Iterable): void { - scheduleSettledFrame(() => { - for (const pane of getPanes()) { - try { - reattachWebglIfNeeded(pane) - resetWebglTextureAtlas(pane) - } catch { - /* ignore — one pane's failure must not block repaint of the rest */ - } + forEachPaneOnSettledFrame(getPanes, (pane) => { + reattachWebglIfNeeded(pane) + resetWebglTextureAtlas(pane) + }) +} + +/** + * Presents already-visible panes without clearing the shared glyph atlas. + * + * Why: a plain window refocus never hid its panes, so their WebGL model is + * already current — a `refresh` re-presents the live buffer (covering a + * compositor that dropped frames while occluded). Using the atlas-clearing + * reveal repaint here would wipe the atlas shared by every same-config pane and + * re-arm the mid-stream page-merge garble race (xterm.js issue 4480); this path + * must stay texture-atlas-preserving. + */ +export function schedulePaneRevealPresent(getPanes: () => Iterable): void { + forEachPaneOnSettledFrame(getPanes, (pane) => { + reattachWebglIfNeeded(pane) + if (pane.terminal.rows > 0) { + pane.terminal.refresh(0, pane.terminal.rows - 1) } }) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5cf3a3c8e..eb46da677 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2223,6 +2223,9 @@ function createWebUiApi(): NonNullable['ui']> { onCloseTerminal: () => noopUnsubscribe, onSleepWorktree: () => noopUnsubscribe, onTerminalZoom: () => noopUnsubscribe, + // Why: a paired web client has no OS sleep signal; occlusion-driven + // visibilitychange already covers its wake recovery. + onSystemResumed: () => noopUnsubscribe, onFileDrop: () => noopUnsubscribe, syncTrafficLights: () => {}, setMarkdownEditorFocused: () => {},