From 0cbb973440cd5b6b8167fd747da53ce562173d0e Mon Sep 17 00:00:00 2001 From: Brynn Bendixen Date: Mon, 26 Oct 2026 08:56:31 +0200 Subject: [PATCH] fix(agent-awake): warn once while the Linux lid sleep assertion keeps failing (#24839) * fix(agent-awake): back off and warn once when the Linux lid sleep assertion keeps failing On a headless Linux host, systemd-inhibit starts and then exits with code 1. start() reset the failure streak right after the spawn, so every 30 s retry logged a fresh warning and the retry never backed off. Keep the streak until an intentional stop or until an inhibitor has held for a full retry interval, and double the retry delay on consecutive failures (30 s up to 30 min). Fixes #20739 Co-Authored-By: Claude Opus 5.5 * fix(agent-awake): keep the lid assertion failure streak across idle/active cycles stop() reset the warn-once dedupe and backoff, so a host where logind denies the inhibitor still warned once per agent turn. Reset only after an inhibitor was actually held for a retry interval. * fix(agent-awake): keep the 30s lid assertion retry instead of backing off Backoff delayed re-acquiring the lid inhibitor on laptops after transient failures; warning once per failure streak already fixes the log flood. --------- Co-authored-by: brynnclaw <261708852+brynnclaw@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/linux-lid-sleep-assertion.test.ts | 113 +++++++++++++++++---- src/main/linux-lid-sleep-assertion.ts | 21 +++- 2 files changed, 111 insertions(+), 23 deletions(-) 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 {