diff --git a/src/main/agent-awake-blocker-swap-retry.test.ts b/src/main/agent-awake-blocker-swap-retry.test.ts new file mode 100644 index 00000000000..cbdb64f5401 --- /dev/null +++ b/src/main/agent-awake-blocker-swap-retry.test.ts @@ -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() + }) +}) diff --git a/src/main/agent-awake-blocker-swap-retry.ts b/src/main/agent-awake-blocker-swap-retry.ts new file mode 100644 index 00000000000..19e62304107 --- /dev/null +++ b/src/main/agent-awake-blocker-swap-retry.ts @@ -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 | 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 + } +} diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index 00e84509431..827bfbcc90b 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -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) => { diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index 80aa60f5243..14aae5f0c88 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -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 | 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,