mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(agent-awake): harden display blocker downgrade
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user