diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 26c1bda9157..6e09c9a1bd1 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6936,7 +6936,9 @@ describe('registerPtyHandlers', () => { expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) - expect(vi.getTimerCount()).toBe(0) + // Why: the only remaining timer is the ack-stall watchdog armed by the + // gate-blocked flush; the flush loop itself stopped rescheduling. + expect(vi.getTimerCount()).toBe(1) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 1, pendingChars: 88 * 1024, @@ -6992,6 +6994,201 @@ describe('registerPtyHandlers', () => { } }) + const ACK_STALL_WATCHDOG_WARNING = + '[pty] ack stall watchdog: resetting in-flight delivery counters' + + function countAckStallWarnings(warnSpy: { mock: { calls: unknown[][] } }): number { + return warnSpy.mock.calls.filter((call) => call[0] === ACK_STALL_WATCHDOG_WARNING).length + } + + /** Saturates one PTY to its 512 KiB in-flight cap so the next flush attempt + * is gate-blocked and arms the ack-stall watchdog. Leaves 88 KiB pending. */ + async function spawnAndSaturateRendererDeliveryGate( + mockProc: ReturnType + ): Promise<{ id: string }> { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + return spawnResult + } + + it('resets the renderer delivery gate after gate-blocked sends see no ACKs for the stall window', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) + expect(countAckStallWarnings(warnSpy)).toBe(0) + + vi.advanceTimersByTime(9_999) + expect(countAckStallWarnings(warnSpy)).toBe(0) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) + + vi.advanceTimersByTime(1) + expect(countAckStallWarnings(warnSpy)).toBe(1) + expect(warnSpy).toHaveBeenCalledWith( + ACK_STALL_WATCHDOG_WARNING, + expect.objectContaining({ + rendererInFlightChars: 512 * 1024, + rendererInFlightPtyCount: 1, + pendingPtyCount: 1 + }) + ) + // Why: the reset must reopen the gate so the held pendingData delivers + // without any renderer ACK ever arriving. + vi.runOnlyPendingTimers() + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) + + for (let index = 0; index < 5; index++) { + vi.advanceTimersByTime(1) + } + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + pendingChars: 0, + rendererInFlightChars: 88 * 1024 + }) + expect(countAckStallWarnings(warnSpy)).toBe(1) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('does not fire the ack-stall watchdog while renderer ACKs keep arriving', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + // Healthy heavy load: delivery stays gate-blocked for far longer than + // the stall window, but ACKs keep trickling in and restart it each time. + for (let round = 0; round < 3; round++) { + vi.advanceTimersByTime(5_000) + ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) + vi.advanceTimersByTime(2) + } + vi.advanceTimersByTime(5_000) + + expect(countAckStallWarnings(warnSpy)).toBe(0) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024 + }) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('disarms the ack-stall watchdog when an ACK arrives and restarts the stall window', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + vi.advanceTimersByTime(9_000) + ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) + + // Past the original 10s deadline: the ACK disarmed the first timer. + vi.advanceTimersByTime(2_000) + expect(countAckStallWarnings(warnSpy)).toBe(0) + + // No further ACKs while still gate-blocked: the re-armed watchdog fires + // one full window after the next gate-blocked send. + vi.advanceTimersByTime(9_000) + expect(countAckStallWarnings(warnSpy)).toBe(1) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('cleans up the ack-stall watchdog when the window is destroyed', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + let destroyed = false + const destroyableWindow = { + isDestroyed: () => destroyed, + webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + } + + try { + registerPtyHandlers(destroyableWindow as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + destroyableWindow.webContents.send.mockClear() + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + expect(destroyableWindow.webContents.send).toHaveBeenCalledTimes(32) + expect(vi.getTimerCount()).toBe(1) + + destroyed = true + mockProc.emitData('post-destroy output') + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(60_000) + expect(countAckStallWarnings(warnSpy)).toBe(0) + expect(destroyableWindow.webContents.send).toHaveBeenCalledTimes(32) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('zeroes renderer in-flight delivery counters when the renderer lifecycle resets', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightPtyCount: 1, + rendererInFlightChars: 512 * 1024 + }) + + handleRendererLoading() + + // Why: reload kills the renderer dispatcher that would have ACKed, so + // keeping the counters would gate PTYs in the fresh renderer forever. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0 + }) + expect(vi.getTimerCount()).toBe(0) + + mockProc.emitData('after-reload') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) + } finally { + vi.useRealTimers() + } + }) + it('forwards only actually in-flight bytes to provider ACK backpressure', async () => { vi.useFakeTimers() const acknowledgeDataEvent = vi.fn() @@ -7085,7 +7282,9 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512) - expect(vi.getTimerCount()).toBe(0) + // Why: the ack-stall watchdog stays armed while bulk delivery is + // gate-blocked; no flush timer remains scheduled. + expect(vi.getTimerCount()).toBe(1) writeListener(mainWindowIpcEvent, { id: interactiveSpawn.id, diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 42806caac11..c364e52c975 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1122,6 +1122,10 @@ let didFinishLoadHandler: (() => void) | null = null let didFinishLoadWebContents: WebContents | null = null let rendererLifecycleResetWebContents: WebContents | null = null let rendererLifecycleResetHandler: (() => void) | null = null +// Why: the ACK in-flight counters live in the registerPtyHandlers closure, but +// renderer lifecycle events are handled at module scope. This indirection lets +// the lifecycle reset zero the closure-owned delivery gate. +let resetRendererInFlightDeliveryGate: () => void = () => {} // Why: the "Restart daemon" path needs to re-bind provider→renderer listeners // against the freshly-created adapter after replaceDaemonProvider swaps the @@ -1193,6 +1197,10 @@ function markRendererPtysHiddenForRendererLifecycleReset(): void { // surviving daemon/SSH PTYs fail closed until the new renderer reports again. activeRendererPtys.clear() visibleRendererPtys.clear() + // Why: a reload/navigation destroys the renderer-side dispatcher, so ACKs + // for already-sent data can never arrive. Stale in-flight counters would + // gate every PTY (including newly created ones) in the fresh renderer. + resetRendererInFlightDeliveryGate() } function clearRendererLifecycleResetHandlers(): void { @@ -1370,6 +1378,9 @@ export function registerPtyHandlers( const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null let rendererInFlightTotalChars = 0 + let ackStallWatchdogTimer: ReturnType | null = null + let ackGateBlockedSinceMs: number | null = null + let lastAckReceivedAtMs: number | null = null const PTY_BATCH_INTERVAL_MS = 8 const PTY_BATCH_DRAIN_CONTINUE_MS = 1 const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 @@ -1380,6 +1391,13 @@ export function registerPtyHandlers( // Why: active panes need a bounded lane through old hidden bulk output so a // keystroke redraw can reach the renderer before every background ACK lands. const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 + // Why: renderer ACKs can be lost across a system suspend, pinning the + // in-flight counters at the cap and silently gating every PTY (old and new) + // forever. Healthy renderers ACK continuously even under heavy load, so a + // full stall window with gate-blocked sends and zero ACKs means the + // outstanding ACKs are gone. A false fire only over-delivers transiently — + // the very next gating cycle bounds delivery again. + const PTY_ACK_STALL_WATCHDOG_MS = 10_000 // Why: keep the immediate path bounded to keystroke-sized TUI redraws; // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 @@ -1515,6 +1533,59 @@ export function registerPtyHandlers( ) } + function disarmAckStallWatchdog(): void { + ackGateBlockedSinceMs = null + if (ackStallWatchdogTimer) { + clearTimeout(ackStallWatchdogTimer) + ackStallWatchdogTimer = null + } + } + + // Why: armed lazily on the first gate-blocked send and disarmed by every + // renderer ACK, so the watchdog never fires under healthy heavy load and + // costs nothing while delivery is unblocked. + function noteAckGateBlockedSend(): void { + if (ackStallWatchdogTimer) { + return + } + ackGateBlockedSinceMs = Date.now() + ackStallWatchdogTimer = setTimeout(resetDeliveryGateAfterAckStall, PTY_ACK_STALL_WATCHDOG_MS) + ackStallWatchdogTimer.unref?.() + } + + function resetDeliveryGateAfterAckStall(): void { + ackStallWatchdogTimer = null + const gateBlockedSinceMs = ackGateBlockedSinceMs + ackGateBlockedSinceMs = null + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed?.() === true) { + return + } + // Why: reaching here means zero ACKs arrived for the full stall window + // (any ACK disarms the timer). Only reset while delivery is still blocked + // on unacked bytes, so an idle-but-healthy renderer is never touched. + if (rendererInFlightTotalChars === 0 || pendingData.size === 0) { + return + } + console.warn('[pty] ack stall watchdog: resetting in-flight delivery counters', { + gateBlockedForMs: gateBlockedSinceMs === null ? null : Date.now() - gateBlockedSinceMs, + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + rendererInFlightChars: rendererInFlightTotalChars, + rendererInFlightPtyCount: rendererInFlightCharsByPty.size, + pendingPtyCount: pendingData.size + }) + rendererInFlightCharsByPty.clear() + rendererInFlightTotalChars = 0 + recordPtyRendererDeliveryPressure() + schedulePendingDataFlush(0) + } + + resetRendererInFlightDeliveryGate = () => { + disarmAckStallWatchdog() + rendererInFlightCharsByPty.clear() + rendererInFlightTotalChars = 0 + recordPtyRendererDeliveryPressure() + } + function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): void { const charCount = getPtyPayloadCharCount(payload) rendererInFlightCharsByPty.set(id, (rendererInFlightCharsByPty.get(id) ?? 0) + charCount) @@ -1606,6 +1677,7 @@ export function registerPtyHandlers( function flushPendingData(): void { flushTimer = null if (mainWindow.isDestroyed()) { + disarmAckStallWatchdog() pendingData.clear() rendererInFlightCharsByPty.clear() rendererInFlightTotalChars = 0 @@ -1618,6 +1690,7 @@ export function registerPtyHandlers( break } if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { + noteAckGateBlockedSend() continue } pendingData.delete(id) @@ -1766,6 +1839,7 @@ export function registerPtyHandlers( clearTimeout(flushTimer) flushTimer = null } + disarmAckStallWatchdog() pendingData.clear() rendererInFlightCharsByPty.clear() rendererInFlightTotalChars = 0 @@ -1799,6 +1873,7 @@ export function registerPtyHandlers( // terminal output already handed to the renderer. The reserve is // bounded, and the per-PTY cap still prevents an active TUI runaway. if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { + noteAckGateBlockedSend() pendingData.set(payload.id, pending) recordPtyRendererDeliveryPressure() return @@ -3435,6 +3510,10 @@ export function registerPtyHandlers( // PTY ingestion. Agent/status consumers still see every chunk through the // provider/runtime path while background renderer writes wait their turn. ipcMain.on('pty:ackData', (_event, args: { id: string; charCount: number }) => { + lastAckReceivedAtMs = Date.now() + // Why: any renderer ACK proves the ACK channel is alive; the stall window + // restarts from the next gate-blocked send. + disarmAckStallWatchdog() const charCount = Number.isFinite(args.charCount) ? Math.max(0, args.charCount) : 0 const current = rendererInFlightCharsByPty.get(args.id) ?? 0 const acknowledged = Math.min(current, charCount) diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 212e7c20f69..f29b432a19b 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -9,6 +9,8 @@ const { menuPopupMock, notificationMock, notificationShowMock, + powerMonitorOnMock, + powerMonitorRemoveListenerMock, isMock } = vi.hoisted(() => { const menuPopupMock = vi.fn() @@ -23,6 +25,8 @@ const { return { show: notificationShowMock } }), notificationShowMock, + powerMonitorOnMock: vi.fn(), + powerMonitorRemoveListenerMock: vi.fn(), isMock: { dev: false } } }) @@ -34,6 +38,7 @@ vi.mock('electron', () => ({ Menu: { buildFromTemplate: buildFromTemplateMock }, Notification: notificationMock, nativeTheme: { shouldUseDarkColors: false }, + powerMonitor: { on: powerMonitorOnMock, removeListener: powerMonitorRemoveListenerMock }, screen: { getPrimaryDisplay: () => ({ workAreaSize: { width: 1440, height: 900 } }) }, @@ -77,6 +82,8 @@ describe('createMainWindow', () => { menuPopupMock.mockClear() notificationMock.mockClear() notificationShowMock.mockClear() + powerMonitorOnMock.mockReset() + powerMonitorRemoveListenerMock.mockReset() isMock.dev = false vi.mocked(ipcMain.on).mockReset() vi.mocked(ipcMain.removeListener).mockReset() @@ -2805,6 +2812,88 @@ describe('createMainWindow', () => { }) }) + describe('system resume relay', () => { + function setupResumeWindow() { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + id: 1 + } + const instance = { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + // Why: maximized keeps forceRepaint from scheduling its size-nudge timer. + isMaximized: vi.fn(() => true), + 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 instance + }) + return { windowHandlers, webContents, instance } + } + + function getPowerResumeListener(): () => void { + const resumeCall = powerMonitorOnMock.mock.calls.find( + (call: unknown[]) => call[0] === 'resume' + ) + if (!resumeCall) { + throw new Error('missing powerMonitor resume listener') + } + return resumeCall[1] as () => void + } + + it('relays powerMonitor resume to the live window and forces a repaint', () => { + const { webContents } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).toHaveBeenCalledWith('system:resumed') + expect(webContents.invalidate).toHaveBeenCalledTimes(1) + }) + + it('does not send the resume event once the window is destroyed', () => { + const { webContents, instance } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + instance.isDestroyed.mockReturnValue(true) + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).not.toHaveBeenCalled() + expect(webContents.invalidate).not.toHaveBeenCalled() + }) + + it('removes the powerMonitor resume listener when the window closes', () => { + const { windowHandlers } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + + windowHandlers.closed() + + expect(powerMonitorRemoveListenerMock).toHaveBeenCalledWith('resume', onResume) + }) + }) + describe('minimize to tray on close (win32)', () => { const originalPlatform = process.platform diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 93f81569ea5..d868272aafd 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -6,6 +6,7 @@ import { Menu, nativeTheme, Notification, + powerMonitor, screen, shell } from 'electron' @@ -339,6 +340,19 @@ export function createMainWindow( }) } + // Why: a focus-preserving system/display wake fires no window focus or + // visibility events in the renderer, so terminal wake recovery would never + // run. Relay powerMonitor resume explicitly (supported on mac/win/linux) + // and force a repaint so stale compositor surfaces recover too. + const onSystemResume = (): void => { + if (mainWindow.isDestroyed()) { + return + } + forceRepaint(mainWindow) + mainWindow.webContents.send('system:resumed') + } + powerMonitor.on('resume', onSystemResume) + mainWindow.webContents.on('dom-ready', () => { const level = store?.getUI().uiZoomLevel ?? 0 mainWindow.webContents.setZoomLevel(level) @@ -1222,6 +1236,9 @@ export function createMainWindow( ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) + // Why: powerMonitor is app-global; without this the closed window's + // resume relay would leak and fire against a destroyed webContents. + powerMonitor.removeListener('resume', onSystemResume) clearTrustedUIRendererWebContentsId(rendererWebContentsId) // Why: on updater-triggered shutdown, BrowserWindow can emit `closed` // after its webContents has already been destroyed. The destroyed diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 35bf793840a..0d37aa624ef 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2699,6 +2699,7 @@ export type PreloadApi = { callback: (payload: RichMarkdownContextMenuCommandPayload) => void ) => () => void onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void + onSystemResumed: (callback: () => void) => () => void minimize: () => void maximize: () => void isMaximized: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 333e298eabe..eff112c63e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3495,6 +3495,14 @@ const api = { ipcRenderer.on('window:fullscreen-changed', listener) return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) }, + /** Fired when the OS resumes from sleep (main relays powerMonitor). A + * focus-preserving display wake fires no renderer focus/visibility + * events, so terminal wake recovery listens to this explicit signal. */ + onSystemResumed: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, /** Desktop custom titlebar only: minimize via renderer-drawn window controls. */ minimize: (): void => { ipcRenderer.send('window:minimize') diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts new file mode 100644 index 00000000000..3174c443c6c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' + +const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({ + recoverVisibleTerminalWindowWakeMock: vi.fn() +})) + +vi.mock('./terminal-visibility-resume', () => ({ + recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock +})) + +import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' + +describe('useTerminalWindowWakeRecovery', () => { + const manager = {} as PaneManager + let systemResumedCallback: (() => void) | null = null + const unsubscribeSystemResumed = vi.fn() + const onSystemResumed = vi.fn((callback: () => void) => { + systemResumedCallback = callback + return unsubscribeSystemResumed + }) + + beforeEach(() => { + systemResumedCallback = null + recoverVisibleTerminalWindowWakeMock.mockClear() + unsubscribeSystemResumed.mockClear() + onSystemResumed.mockClear() + // Why: without requestAnimationFrame the hook skips its settled-frame + // follow-up, so every trigger maps to exactly one synchronous recovery. + vi.stubGlobal('requestAnimationFrame', undefined) + ;(window as unknown as { api: unknown }).api = { ui: { onSystemResumed } } + }) + + afterEach(() => { + vi.unstubAllGlobals() + delete (window as unknown as { api?: unknown }).api + }) + + function renderWakeRecoveryHook(isVisible = true) { + return renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + } + + it('runs the same wake recovery for system resume as for window focus', () => { + renderWakeRecoveryHook() + + window.dispatchEvent(new Event('focus')) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenCalledTimes(1) + + expect(systemResumedCallback).toBeTypeOf('function') + systemResumedCallback?.() + + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenCalledTimes(2) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(2, { + manager, + isActive: true + }) + expect(recoverVisibleTerminalWindowWakeMock.mock.calls[1]).toEqual( + recoverVisibleTerminalWindowWakeMock.mock.calls[0] + ) + }) + + it('unsubscribes from the system resume event on cleanup', () => { + const { unmount } = renderWakeRecoveryHook() + expect(onSystemResumed).toHaveBeenCalledTimes(1) + + unmount() + + expect(unsubscribeSystemResumed).toHaveBeenCalledTimes(1) + }) + + it('does not subscribe while the terminal surface is hidden', () => { + renderWakeRecoveryHook(false) + + expect(onSystemResumed).not.toHaveBeenCalled() + }) +}) 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 8d07259f7ae..0f922235b7f 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 @@ -66,12 +66,18 @@ export function useTerminalWindowWakeRecovery({ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } + // Why: a focus-preserving display wake fires neither focus nor + // visibilitychange, so main relays powerMonitor resume over IPC. Same + // recovery path — it resumes pane rendering and clears the WebGL latch. + const unsubscribeSystemResumed = + window.api?.ui.onSystemResumed?.(() => recoverVisibleWake()) ?? 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/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 1da70b789e5..1d3d4629a71 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2229,6 +2229,9 @@ function createWebUiApi(): NonNullable['ui']> { setShortcutRecorderFocused: () => {}, onRichMarkdownContextCommand: () => noopUnsubscribe, onFullscreenChanged: () => noopUnsubscribe, + // Why: web clients run in a browser tab; the host OS resume signal only + // exists in the Electron shell. + onSystemResumed: () => noopUnsubscribe, minimize: () => {}, maximize: () => {}, onMaximizeChanged: () => noopUnsubscribe,