Recover terminal output delivery after system sleep

Root cause: main gates every pty:data send on a global + per-PTY
in-flight counter that only renderer ACKs decrement. If ACKs are lost
across a system suspend, the counters pin at the cap and every PTY —
old and newly created — is silently gated forever while output piles up
in pendingData. A focus-preserving display wake also fires no renderer
focus/visibilitychange events, so terminal wake recovery (and the WebGL
context-loss latch clear) never runs. Only a renderer reload recovered.

Three fixes:
- ACK-stall watchdog (src/main/ipc/pty.ts): if sends stay gate-blocked
  for 10s with zero ACK progress while the renderer webContents is
  alive, warn once, reset the in-flight delivery counters, and flush
  held pendingData. Armed lazily on the first gate-blocked send and
  disarmed by every ACK, so it can never fire under healthy heavy load.
- Renderer lifecycle reset now also zeroes the in-flight counters — a
  reload/navigation destroys the renderer dispatcher, so outstanding
  ACKs can never arrive and stale counters would gate the new renderer.
- System-resume wake IPC: main relays powerMonitor 'resume' as
  system:resumed to live windows (plus forceRepaint); preload exposes
  ui.onSystemResumed; the terminal wake-recovery hook runs the same
  recovery path as window focus/visibilitychange.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 16:11:18 -04:00
co-authored by Orca
parent 492306f223
commit a4ec218266
9 changed files with 489 additions and 2 deletions
+201 -2
View File
@@ -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<typeof createMockProc>
): 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,
+79
View File
@@ -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<string>()
let flushTimer: ReturnType<typeof setTimeout> | null = null
let rendererInFlightTotalChars = 0
let ackStallWatchdogTimer: ReturnType<typeof setTimeout> | 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)
+89
View File
@@ -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<string, (...args: any[]) => 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
+17
View File
@@ -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
+1
View File
@@ -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<boolean>
+8
View File
@@ -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')
@@ -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()
})
})
@@ -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])
}
+3
View File
@@ -2229,6 +2229,9 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['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,