diff --git a/src/main/linux-lid-sleep-assertion.test.ts b/src/main/linux-lid-sleep-assertion.test.ts index 2c2db3d4382..9c42f3630e6 100644 --- a/src/main/linux-lid-sleep-assertion.test.ts +++ b/src/main/linux-lid-sleep-assertion.test.ts @@ -315,26 +315,103 @@ describe('LinuxLidSleepAssertion', () => { vi.useRealTimers() }) - it('logs repeated identical failures at debug until reset', () => { - const logger = createLogger() - const spawn = vi.fn(() => { - throw new Error('dbus unavailable') - }) - let now = 1_000 - const assertion = new LinuxLidSleepAssertion({ - logger, - now: () => now, - platform: 'linux', - spawn + describe('when systemd-inhibit starts but exits immediately (no usable logind)', () => { + function createRetryingAssertion() { + const logger = createLogger() + const children: FakeSystemdInhibitProcess[] = [] + const spawnTimes: number[] = [] + const spawn = vi.fn(() => { + const child = new FakeSystemdInhibitProcess() + children.push(child) + spawnTimes.push(Date.now()) + return child + }) + const assertion: LinuxLidSleepAssertion = new LinuxLidSleepAssertion({ + logger, + now: () => Date.now(), + // Mirrors AgentAwakeService: every failure or retry tick re-runs start(). + onUnexpectedFailure: (reason) => assertion.start(reason), + platform: 'linux', + spawn + }) + const failLatestChild = (): void => { + const child = children.at(-1) + child?.emit('exit', 1, null) + child?.emit('close', 1, null) + } + const spawnGaps = (): number[] => + spawnTimes.slice(1).map((time, index) => time - spawnTimes[index]) + return { assertion, failLatestChild, logger, spawn, spawnGaps } + } + + it('warns once while it keeps retrying every interval', () => { + vi.useFakeTimers() + try { + const { assertion, failLatestChild, logger, spawnGaps } = createRetryingAssertion() + + assertion.start('status-change') + for (let attempt = 0; attempt < 8; attempt++) { + failLatestChild() + vi.advanceTimersToNextTimer() + } + + expect(spawnGaps()).toEqual(Array(8).fill(LINUX_LID_SLEEP_ASSERTION_RETRY_MS)) + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.debug).toHaveBeenCalledTimes(7) + assertion.dispose() + } finally { + vi.useRealTimers() + } }) - assertion.start('status-change') - now += LINUX_LID_SLEEP_ASSERTION_RETRY_MS + 1 - assertion.start('power-resume') - assertion.stop('settings-change') - assertion.start('status-change') + it('stays quiet across idle/active cycles until an inhibitor is held', () => { + vi.useFakeTimers() + try { + const { assertion, failLatestChild, logger, spawn } = createRetryingAssertion() - expect(logger.warn).toHaveBeenCalledTimes(2) - expect(logger.debug).toHaveBeenCalledTimes(1) + assertion.start('status-change') + for (let attempt = 0; attempt < 4; attempt++) { + failLatestChild() + vi.advanceTimersToNextTimer() + } + assertion.stop('status-change') + assertion.start('status-change') + failLatestChild() + + expect(spawn).toHaveBeenCalledTimes(6) + expect(logger.warn).toHaveBeenCalledTimes(1) + + vi.advanceTimersToNextTimer() + vi.advanceTimersByTime(LINUX_LID_SLEEP_ASSERTION_RETRY_MS) + assertion.stop('status-change') + assertion.start('status-change') + failLatestChild() + + expect(logger.warn).toHaveBeenCalledTimes(2) + assertion.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('treats an inhibitor that held for a full retry interval as recovered', () => { + vi.useFakeTimers() + try { + const { assertion, failLatestChild, logger } = createRetryingAssertion() + + assertion.start('status-change') + for (let attempt = 0; attempt < 4; attempt++) { + failLatestChild() + vi.advanceTimersToNextTimer() + } + vi.advanceTimersByTime(LINUX_LID_SLEEP_ASSERTION_RETRY_MS) + failLatestChild() + + expect(logger.warn).toHaveBeenCalledTimes(2) + assertion.dispose() + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/src/main/linux-lid-sleep-assertion.ts b/src/main/linux-lid-sleep-assertion.ts index 0fe3312e0c0..9fcd243b2a4 100644 --- a/src/main/linux-lid-sleep-assertion.ts +++ b/src/main/linux-lid-sleep-assertion.ts @@ -43,6 +43,7 @@ export class LinuxLidSleepAssertion { private readonly platform: NodeJS.Platform private readonly spawn: SystemdInhibitSpawn private child: SystemdInhibitProcess | null = null + private childStartedAt: number | null = null private retryNotBefore: number | null = null private retryTimer: ReturnType | null = null private systemdInhibitUnavailable = false @@ -94,6 +95,7 @@ export class LinuxLidSleepAssertion { } this.child = child + this.childStartedAt = this.now() const onError: SystemdInhibitErrorListener = (error) => { this.handleChildFailure( child, @@ -119,18 +121,19 @@ export class LinuxLidSleepAssertion { child.on('exit', onExit) child.on('close', onClose) child.stdin?.on('error', onError) + // Not a held lock yet: systemd-inhibit can still exit 1 right after spawning. this.resetRetrySuppression() - this.resetFailureStreak() } stop(_reason: string): void { this.resetRetrySuppression() - this.resetFailureStreak() if (!this.child) { return } const child = this.child + this.resetFailureStreakIfHeld() this.child = null + this.childStartedAt = null // Pipe and child errors can arrive after stop; close ends both event sources. this.intentionalStops.add(child) try { @@ -166,7 +169,9 @@ export class LinuxLidSleepAssertion { } this.reportedFailures.add(child) if (this.child === child) { + this.resetFailureStreakIfHeld() this.child = null + this.childStartedAt = null } this.handleFailure(failureKey, startReason, details, failureType) } @@ -218,9 +223,15 @@ export class LinuxLidSleepAssertion { this.logger.warn('[agent-awake] Linux lid sleep assertion failed', payload) } - private resetFailureStreak(): void { - this.lastFailureKey = null - this.warnedForLastFailure = false + // Only a lock held for a retry interval ends the streak; idle/active cycles keep it. + private resetFailureStreakIfHeld(): void { + if ( + this.childStartedAt !== null && + this.now() - this.childStartedAt >= LINUX_LID_SLEEP_ASSERTION_RETRY_MS + ) { + this.lastFailureKey = null + this.warnedForLastFailure = false + } } private scheduleRetry(): void {