mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
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:
+201
-2
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user