fix(main): verify the macOS sleep assertion is still held

start() returned true whenever a child had ever been spawned, and
refresh() stops the Electron blocker on that word -- so a caffeinate
that died without delivering an exit event left the machine with no
assertion at all, and nothing ever re-checked. Measured: 15h49m awake
with the setting on and pmset showing zero Orca-owned assertions.

Verify the recorded pid instead of trusting the handle, and run a
watchdog while the assertion is meant to be held so a vanished child
re-arms. Mirrors reconcileBlocker(), which already re-checks the
Electron blocker with isStarted().
This commit is contained in:
Merge Sim
2026-09-11 11:05:06 -07:00
parent 08a24efaba
commit 503162cf98
2 changed files with 136 additions and 2 deletions
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import {
MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS,
MACOS_SYSTEM_SLEEP_ASSERTION_WATCHDOG_MS,
MacosSystemSleepAssertion
} from './macos-system-sleep-assertion'
@@ -56,6 +57,7 @@ describe('MacosSystemSleepAssertion', () => {
const assertion = new MacosSystemSleepAssertion({
logger: createLogger(),
platform: 'darwin',
isProcessAlive: () => true,
spawn
})
@@ -220,4 +222,70 @@ describe('MacosSystemSleepAssertion', () => {
expect(logger.debug).toHaveBeenCalledTimes(1)
assertion.dispose()
})
it('respawns when the recorded child vanished without an exit event', () => {
const spawn = vi.fn(() => new FakeCaffeinateProcess())
const assertion = new MacosSystemSleepAssertion({
logger: createLogger(),
platform: 'darwin',
isProcessAlive: () => false,
spawn
})
assertion.start('status-change')
expect(assertion.start('status-change')).toBe(true)
// Without a liveness check the second start short-circuits and the OS holds nothing.
expect(spawn).toHaveBeenCalledTimes(2)
assertion.dispose()
})
it('reports a vanished assertion from the watchdog so the service re-arms', () => {
vi.useFakeTimers()
try {
let alive = true
const onUnexpectedFailure = vi.fn()
const assertion = new MacosSystemSleepAssertion({
logger: createLogger(),
platform: 'darwin',
isProcessAlive: () => alive,
onUnexpectedFailure,
spawn: vi.fn(() => new FakeCaffeinateProcess())
})
assertion.start('status-change')
vi.advanceTimersByTime(MACOS_SYSTEM_SLEEP_ASSERTION_WATCHDOG_MS)
expect(onUnexpectedFailure).not.toHaveBeenCalled()
alive = false
vi.advanceTimersByTime(MACOS_SYSTEM_SLEEP_ASSERTION_WATCHDOG_MS)
expect(onUnexpectedFailure).toHaveBeenCalledWith('macos-assertion-vanished')
assertion.dispose()
} finally {
vi.useRealTimers()
}
})
it('stops the watchdog once the assertion is intentionally released', () => {
vi.useFakeTimers()
try {
const onUnexpectedFailure = vi.fn()
const assertion = new MacosSystemSleepAssertion({
logger: createLogger(),
platform: 'darwin',
isProcessAlive: () => false,
onUnexpectedFailure,
spawn: vi.fn(() => new FakeCaffeinateProcess())
})
assertion.start('status-change')
assertion.stop('settings-change')
vi.advanceTimersByTime(MACOS_SYSTEM_SLEEP_ASSERTION_WATCHDOG_MS * 3)
expect(onUnexpectedFailure).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
+68 -2
View File
@@ -1,6 +1,9 @@
import { spawn as nodeSpawn } from 'node:child_process'
export const MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS = 30_000
// Why: a killed or reaped caffeinate can leave `child` set with no exit event, and the
// service stops the Electron blocker on that word — so the belief must be re-verified.
export const MACOS_SYSTEM_SLEEP_ASSERTION_WATCHDOG_MS = 30_000
type Logger = Pick<Console, 'debug' | 'warn'>
@@ -28,6 +31,8 @@ type MacosSystemSleepAssertionOptions = {
onUnexpectedFailure?: (reason: string) => void
platform?: NodeJS.Platform
spawn?: CaffeinateSpawn
isProcessAlive?: (pid: number) => boolean
watchdogIntervalMs?: number
}
export class MacosSystemSleepAssertion {
@@ -36,7 +41,11 @@ export class MacosSystemSleepAssertion {
private readonly onUnexpectedFailure: (reason: string) => void
private readonly platform: NodeJS.Platform
private readonly spawn: CaffeinateSpawn
private readonly isProcessAlive: (pid: number) => boolean
private readonly watchdogIntervalMs: number
private child: CaffeinateProcess | null = null
private childPid: number | null = null
private watchdogTimer: ReturnType<typeof setInterval> | null = null
private retryNotBefore: number | null = null
private retryTimer: ReturnType<typeof setTimeout> | null = null
private lastFailureKey: string | null = null
@@ -51,6 +60,8 @@ export class MacosSystemSleepAssertion {
this.onUnexpectedFailure = options.onUnexpectedFailure ?? (() => {})
this.platform = options.platform ?? process.platform
this.spawn = options.spawn ?? nodeSpawn
this.isProcessAlive = options.isProcessAlive ?? processIsAlive
this.watchdogIntervalMs = options.watchdogIntervalMs ?? MACOS_SYSTEM_SLEEP_ASSERTION_WATCHDOG_MS
}
start(reason: string): boolean {
@@ -58,7 +69,10 @@ export class MacosSystemSleepAssertion {
return false
}
if (this.child) {
return true
if (this.isChildAlive()) {
return true
}
this.forgetChild()
}
if (this.retryNotBefore !== null && this.now() < this.retryNotBefore) {
this.scheduleRetry()
@@ -77,6 +91,7 @@ export class MacosSystemSleepAssertion {
}
this.child = child
this.childPid = typeof child.pid === 'number' ? child.pid : null
const onError: CaffeinateErrorListener = (error) => {
this.handleChildFailure(child, `error:${String(error.message)}`, 'error', reason, error)
}
@@ -92,6 +107,7 @@ export class MacosSystemSleepAssertion {
})
child.on('error', onError)
child.on('exit', onExit)
this.armWatchdog()
this.resetRetrySuppression()
this.resetFailureStreak()
return true
@@ -105,6 +121,8 @@ export class MacosSystemSleepAssertion {
}
const child = this.child
this.child = null
this.childPid = null
this.clearWatchdog()
this.intentionalStops.add(child)
this.detachChildListeners(child)
try {
@@ -139,7 +157,7 @@ export class MacosSystemSleepAssertion {
}
this.reportedFailures.add(child)
if (this.child === child) {
this.child = null
this.forgetChild()
}
this.handleFailure(failureKey, startReason, details, failureType)
}
@@ -185,6 +203,44 @@ export class MacosSystemSleepAssertion {
this.logger.warn('[agent-awake] macOS system sleep assertion failed', payload)
}
private isChildAlive(): boolean {
// No pid means an injected test double; there is nothing to verify against.
return this.childPid === null ? true : this.isProcessAlive(this.childPid)
}
private forgetChild(): void {
const child = this.child
this.child = null
this.childPid = null
this.clearWatchdog()
if (child) {
this.detachChildListeners(child)
}
}
private armWatchdog(): void {
this.clearWatchdog()
this.watchdogTimer = setInterval(() => {
if (this.isChildAlive()) {
return
}
this.logger.warn('[agent-awake] macOS system sleep assertion vanished without an exit event')
this.forgetChild()
this.onUnexpectedFailure('macos-assertion-vanished')
}, this.watchdogIntervalMs)
if (typeof this.watchdogTimer.unref === 'function') {
this.watchdogTimer.unref()
}
}
private clearWatchdog(): void {
if (!this.watchdogTimer) {
return
}
clearInterval(this.watchdogTimer)
this.watchdogTimer = null
}
private scheduleRetry(): void {
if (this.retryNotBefore === null || this.retryTimer) {
return
@@ -214,6 +270,16 @@ export class MacosSystemSleepAssertion {
}
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
// EPERM means the pid is live but not ours; only ESRCH proves it is gone.
return !isEsrchError(error)
}
}
function isEsrchError(error: unknown): boolean {
return (
typeof error === 'object' &&