fix(agent-awake): harden display blocker downgrade

This commit is contained in:
Brennan Benson
2026-07-14 02:29:06 -07:00
parent fb290efa58
commit 32e2358e9d
4 changed files with 113 additions and 11 deletions
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
AgentAwakeBlockerSwapRetry,
AGENT_AWAKE_BLOCKER_SWAP_RETRY_MS
} from './agent-awake-blocker-swap-retry'
describe('AgentAwakeBlockerSwapRetry', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('makes at most one retry for the same blocker target', () => {
let retry: AgentAwakeBlockerSwapRetry
const callback = vi.fn(() => retry.schedule('prevent-app-suspension'))
retry = new AgentAwakeBlockerSwapRetry(callback)
retry.schedule('prevent-app-suspension')
vi.advanceTimersByTime(AGENT_AWAKE_BLOCKER_SWAP_RETRY_MS * 10)
expect(callback).toHaveBeenCalledTimes(1)
})
it('cancels a pending retry when cleared', () => {
const callback = vi.fn()
const retry = new AgentAwakeBlockerSwapRetry(callback)
retry.schedule('prevent-app-suspension')
retry.clear()
vi.advanceTimersByTime(AGENT_AWAKE_BLOCKER_SWAP_RETRY_MS)
expect(callback).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,35 @@
export const AGENT_AWAKE_BLOCKER_SWAP_RETRY_MS = 1_000
type AgentAwakeBlockerType = 'prevent-app-suspension' | 'prevent-display-sleep'
export class AgentAwakeBlockerSwapRetry {
private target: AgentAwakeBlockerType | null = null
private timer: ReturnType<typeof setTimeout> | null = null
constructor(private readonly retry: () => void) {}
schedule(target: AgentAwakeBlockerType): void {
if (this.target === target) {
return
}
this.clear()
this.target = target
// Why: one prompt retry recovers a transient Electron stop failure without
// turning a quiet-agent power transition into a polling loop.
this.timer = setTimeout(() => {
this.timer = null
this.retry()
}, AGENT_AWAKE_BLOCKER_SWAP_RETRY_MS)
if (typeof this.timer.unref === 'function') {
this.timer.unref()
}
}
clear(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
this.target = null
}
}
+34 -8
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_AWAKE_BLOCKER_SWAP_RETRY_MS } from './agent-awake-blocker-swap-retry'
import {
AgentAwakeService,
AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS,
@@ -164,9 +165,10 @@ describe('AgentAwakeService', () => {
macosAssertion.stop.mockClear()
linuxAssertion.stop.mockClear()
// Cross the display-keep boundary with no newer status.
now = 1_000 + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS + 1
vi.advanceTimersByTime(AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS + 1)
// Hit the exact boundary: it must be expired here or the scheduler will
// move directly to the 2h system expiry and leave the display blocker on.
now = 1_000 + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS
vi.advanceTimersByTime(AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS)
// The display assertion is swapped for a system-only one; the platform
// (system) assertions are never stopped, so the agent keeps running.
@@ -193,7 +195,7 @@ describe('AgentAwakeService', () => {
service.dispose()
})
it('does not stack a second blocker if a display->system swap cannot stop the old one', () => {
it('retries a transient display->system swap failure without stacking blockers', () => {
vi.useFakeTimers()
let now = 1_000
const blocker = createBlocker()
@@ -205,19 +207,43 @@ describe('AgentAwakeService', () => {
// The swap tries to stop id 1 first, but stop fails and Electron still
// reports it started — the old assertion must NOT be leaked under a new one.
blocker.stop.mockImplementation(() => {
blocker.stop.mockImplementationOnce(() => {
throw new Error('stop failed')
})
blocker.isStarted.mockReturnValue(true)
blocker.start.mockClear()
now = 1_000 + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS + 1
vi.advanceTimersByTime(AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS + 1)
now = 1_000 + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS
vi.advanceTimersByTime(AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS)
expect(blocker.start).not.toHaveBeenCalled()
vi.advanceTimersByTime(AGENT_AWAKE_BLOCKER_SWAP_RETRY_MS)
expect(blocker.stop).toHaveBeenCalledTimes(2)
expect(blocker.start).toHaveBeenCalledTimes(1)
expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension')
service.dispose()
})
it('clears a pending blocker-swap retry on dispose', () => {
vi.useFakeTimers()
let now = 1_000
const blocker = createBlocker()
const service = createService(() => now, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
blocker.stop.mockImplementationOnce(() => {
throw new Error('stop failed')
})
now = 1_000 + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS
vi.advanceTimersByTime(AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS)
service.dispose()
blocker.stop.mockClear()
vi.advanceTimersByTime(AGENT_AWAKE_BLOCKER_SWAP_RETRY_MS)
expect(blocker.stop).not.toHaveBeenCalled()
})
it.each(['win32', 'linux'] as const)(
'never downgrades the display on %s (only macOS has a canonical system-sleep floor)',
(platform) => {
+12 -3
View File
@@ -1,5 +1,6 @@
import { powerMonitor, powerSaveBlocker } from 'electron'
import type { AgentStatusState } from '../shared/agent-status-types'
import { AgentAwakeBlockerSwapRetry } from './agent-awake-blocker-swap-retry'
import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion'
import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion'
@@ -65,6 +66,7 @@ export class AgentAwakeService {
private statuses: AgentAwakeStatus[] = []
private blockerId: number | null = null
private blockerType: AgentAwakeBlockerType | null = null
private readonly blockerSwapRetry: AgentAwakeBlockerSwapRetry
private staleTimer: ReturnType<typeof setTimeout> | null = null
private readonly blocker: PowerSaveBlocker
private readonly linuxAssertion: PlatformAwakeAssertion
@@ -79,6 +81,7 @@ export class AgentAwakeService {
this.logger = options.logger ?? console
this.now = options.now ?? Date.now
this.platform = options.platform ?? process.platform
this.blockerSwapRetry = new AgentAwakeBlockerSwapRetry(() => this.refresh('blocker-swap-retry'))
// Windows lid close is intentionally not modeled as an assertion here:
// keeping it awake requires mutating the user's global power plan.
this.linuxAssertion =
@@ -120,6 +123,7 @@ export class AgentAwakeService {
dispose(): void {
this.clearStaleTimer()
this.blockerSwapRetry.clear()
this.unsubscribeResume?.()
this.stopBlocker('dispose')
this.macosAssertion.dispose()
@@ -152,6 +156,7 @@ export class AgentAwakeService {
this.startMacosAssertion(reason)
this.startLinuxAssertion(reason)
} else {
this.blockerSwapRetry.clear()
this.stopBlocker(reason, runningStatusCount)
this.stopMacosAssertion(reason)
this.stopLinuxAssertion(reason)
@@ -166,7 +171,7 @@ export class AgentAwakeService {
return this.statuses.filter(
(status) =>
this.isWakeEligible(status, now) &&
now - status.receivedAt <= AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS
now - status.receivedAt < AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS
).length
}
@@ -232,6 +237,7 @@ export class AgentAwakeService {
// display<->system transition must swap the assertion.
if (this.blockerId !== null && this.blockerType === desiredType) {
if (this.reconcileBlocker('start-reconcile')) {
this.blockerSwapRetry.clear()
return
}
}
@@ -242,7 +248,8 @@ export class AgentAwakeService {
if (this.blockerId !== null) {
// The old blocker could not be stopped (stop threw and Electron still
// reports it started). Do NOT stack a second assertion — keep the
// current one and retry the swap on the next refresh.
// current one and make one prompt retry without creating a poll loop.
this.blockerSwapRetry.schedule(desiredType)
return
}
}
@@ -250,7 +257,9 @@ export class AgentAwakeService {
const id = this.blocker.start(desiredType)
this.blockerId = id
this.blockerType = desiredType
this.reconcileBlocker('post-start')
if (this.reconcileBlocker('post-start')) {
this.blockerSwapRetry.clear()
}
} catch (err) {
this.logger.warn('[agent-awake] failed to start blocker', {
reason,