Files
orca/src/main/linux-lid-sleep-assertion.ts
T
Neil 1912c90141 Extract subprocess handling into @orca/process-host (#26963)
Extract subprocess execution and process contracts into @orca/process-host with explicit public entry points, independent checks, and one Node 18 compatible CommonJS runtime. Migrate application consumers and integrate package builds, dependency selection, import enforcement, and runtime resource staging.

Preserve daemon startup ownership and profile isolation, and qualify development rebuild recovery and concurrent output publication. Remove unnecessary aliases, test forwarding exports, and a duplicate output scan.

Validation: full typechecks, changed-code quality, package and artifact checks, native Windows builds/types plus 306 runtime tests, and independent native reviews.

Matched full E2E controls on original main and unchanged reviewed head both pass all 17 applicable tests (nine skips). Earlier intermittent E2E failures are documented in the PR; no assertions were weakened. The final combined-tree audit preserves both branches with no import-boundary violations.
2026-10-09 18:32:28 -07:00

268 lines
7.8 KiB
TypeScript

import { spawnProcess } from '@orca/process-host'
export const LINUX_LID_SLEEP_ASSERTION_RETRY_MS = 30_000
type Logger = Pick<Console, 'debug' | 'warn'>
type SystemdInhibitErrorListener = (error: Error & { code?: string }) => void
type SystemdInhibitExitListener = (code: number | null, signal: NodeJS.Signals | null) => void
type SystemdInhibitProcess = {
kill: () => boolean
stdin: {
destroy(): void
on(event: 'error', listener: SystemdInhibitErrorListener): void
} | null
on(event: 'error', listener: SystemdInhibitErrorListener): void
on(event: 'exit', listener: SystemdInhibitExitListener): void
on(event: 'close', listener: () => void): void
off(event: 'error', listener: SystemdInhibitErrorListener): void
off(event: 'exit', listener: SystemdInhibitExitListener): void
off(event: 'close', listener: () => void): void
pid?: number
}
type SystemdInhibitSpawn = (
command: string,
args: string[],
options: { stdio: ['pipe', 'ignore', 'ignore']; windowsHide: true; shell?: false }
) => SystemdInhibitProcess
type LinuxLidSleepAssertionOptions = {
logger?: Logger
now?: () => number
onUnexpectedFailure?: (reason: string) => void
platform?: NodeJS.Platform
spawn?: SystemdInhibitSpawn
}
export class LinuxLidSleepAssertion {
private readonly logger: Logger
private readonly now: () => number
private readonly onUnexpectedFailure: (reason: string) => void
private readonly platform: NodeJS.Platform
private readonly spawn: SystemdInhibitSpawn
private child: SystemdInhibitProcess | null = null
private retryNotBefore: number | null = null
private retryTimer: ReturnType<typeof setTimeout> | null = null
private systemdInhibitUnavailable = false
private lastFailureKey: string | null = null
private warnedForLastFailure = false
private readonly intentionalStops = new WeakSet<SystemdInhibitProcess>()
private readonly reportedFailures = new WeakSet<SystemdInhibitProcess>()
private readonly childCleanups = new WeakMap<SystemdInhibitProcess, () => void>()
constructor(options: LinuxLidSleepAssertionOptions = {}) {
this.logger = options.logger ?? console
this.now = options.now ?? Date.now
this.onUnexpectedFailure = options.onUnexpectedFailure ?? (() => {})
this.platform = options.platform ?? process.platform
this.spawn =
options.spawn ?? ((program, args, { stdio }) => spawnProcess({ program, args, stdio }))
}
start(reason: string): void {
if (this.platform !== 'linux' || this.child || this.systemdInhibitUnavailable) {
return
}
if (this.retryNotBefore !== null && this.now() < this.retryNotBefore) {
this.scheduleRetry()
return
}
let child: SystemdInhibitProcess
try {
// logind's lid switch handling ignores ordinary sleep inhibitors on many systems,
// so Linux needs both a sleep lock and a handle-lid-switch lock.
child = this.spawn(
'systemd-inhibit',
[
'--what=sleep:handle-lid-switch',
'--who=Orca',
'--why=Agents are working',
'--mode=block',
// EOF releases the inhibitor even when Orca is killed without running cleanup.
'cat'
],
{
stdio: ['pipe', 'ignore', 'ignore'],
windowsHide: true
}
)
} catch (error) {
this.handleFailure('spawn-error', reason, error, 'spawn-error')
return
}
this.child = child
const onError: SystemdInhibitErrorListener = (error) => {
this.handleChildFailure(
child,
`error:${String(error.code ?? error.message)}`,
'error',
reason,
error
)
}
const onExit: SystemdInhibitExitListener = (code, signal) => {
this.handleChildFailure(child, `exit:${String(code)}:${String(signal)}`, 'exit', reason, {
code,
signal
})
}
const onClose = (): void => this.detachChildListeners(child)
this.childCleanups.set(child, () => {
child.off('error', onError)
child.off('exit', onExit)
child.off('close', onClose)
})
child.on('error', onError)
child.on('exit', onExit)
child.on('close', onClose)
child.stdin?.on('error', onError)
this.resetRetrySuppression()
this.resetFailureStreak()
}
stop(_reason: string): void {
this.resetRetrySuppression()
this.resetFailureStreak()
if (!this.child) {
return
}
const child = this.child
this.child = null
// Pipe and child errors can arrive after stop; close ends both event sources.
this.intentionalStops.add(child)
try {
if (child.stdin) {
child.stdin.destroy()
} else {
child.kill()
}
} catch (error) {
if (!isEsrchError(error)) {
this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', { error })
}
}
}
dispose(): void {
this.stop('dispose')
}
private handleChildFailure(
child: SystemdInhibitProcess,
failureKey: string,
failureType: 'error' | 'exit',
startReason: string,
details: unknown
): void {
child.stdin?.destroy()
if (this.intentionalStops.has(child)) {
return
}
if (this.reportedFailures.has(child)) {
return
}
this.reportedFailures.add(child)
if (this.child === child) {
this.child = null
}
this.handleFailure(failureKey, startReason, details, failureType)
}
private detachChildListeners(child: SystemdInhibitProcess): void {
const cleanup = this.childCleanups.get(child)
if (!cleanup) {
return
}
cleanup()
this.childCleanups.delete(child)
}
private handleFailure(
failureKey: string,
reason: string,
details: unknown,
failureType: 'error' | 'exit' | 'spawn-error'
): void {
if (isMissingSystemdInhibit(details)) {
this.systemdInhibitUnavailable = true
this.resetRetrySuppression()
this.logFailure('systemd-inhibit-missing', reason, details, failureType)
return
}
this.logFailure(failureKey, reason, details, failureType)
this.retryNotBefore = this.now() + LINUX_LID_SLEEP_ASSERTION_RETRY_MS
this.scheduleRetry()
this.onUnexpectedFailure('linux-lid-assertion-failure')
}
private logFailure(
failureKey: string,
reason: string,
details: unknown,
failureType: 'error' | 'exit' | 'spawn-error'
): void {
const payload = {
reason,
failureType,
details
}
if (this.lastFailureKey === failureKey && this.warnedForLastFailure) {
this.logger.debug('[agent-awake] Linux lid sleep assertion failed repeatedly', payload)
return
}
this.lastFailureKey = failureKey
this.warnedForLastFailure = true
this.logger.warn('[agent-awake] Linux lid sleep assertion failed', payload)
}
private resetFailureStreak(): void {
this.lastFailureKey = null
this.warnedForLastFailure = false
}
private scheduleRetry(): void {
if (this.retryNotBefore === null || this.retryTimer) {
return
}
const retryDelay = Math.max(0, this.retryNotBefore - this.now())
this.retryTimer = setTimeout(() => {
this.retryTimer = null
this.onUnexpectedFailure('linux-lid-assertion-retry')
}, retryDelay)
if (typeof this.retryTimer.unref === 'function') {
this.retryTimer.unref()
}
}
private resetRetrySuppression(): void {
this.retryNotBefore = null
if (!this.retryTimer) {
return
}
clearTimeout(this.retryTimer)
this.retryTimer = null
}
}
function isMissingSystemdInhibit(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'ENOENT'
)
}
function isEsrchError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'ESRCH'
)
}