Files
orca/src/main/daemon/daemon-launch-paths.ts
T
Neil e17c98d425 fix(daemon): bound the whole boot-recovery sequence with one budget (STA-5732) (#17427)
* fix(daemon): bound the whole boot-recovery sequence with one budget (STA-5732)

* fix(daemon): keep socket probes inside recovery budget

* fix(daemon): size the recovery budget against the real post-kill tail

The 24s budget reserved only 9s for everything after the deadline, leaving
27s of the startup PTY gate's fail-open cap unused — and every unused second
is one where a daemon that would have drained gets killed with its live PTYs
instead. Reserve each post-deadline stage's actual hard cap (kill 10.5s, fork
10s, lease 5s) and spend the rest: 24s -> 32s of adopt window.

* fix(daemon): keep the last-resort endpoint rescue outside the recovery budget

The rescue probe in the launcher's outer catch was clamped to the recovery
budget's remainder, but it runs *after* that budget by construction — past
prepareDaemonReplacement, killStaleDaemon, the fork and the adoption lease.
The remainder is therefore essentially always negative, so Math.max(1, ...)
handed a live socket a 1ms connect window. On the loaded machine this path
exists for the probe loses to its own timer, the launcher rethrows, and a
recoverable degraded adoption becomes total daemon loss for the whole run —
the outcome the comment above it exists to prevent. Restore the 1s default
and pin the window with a test that drives the launcher to that catch with
the budget already spent.

Also make the deliberate narrowing legible instead of implicit:

- daemon-recovery-budget.ts: TRANSIENT_WEDGE_DRAIN_MS documented 20s as the
  grace #8697 sized, but #8697's merged second commit (840d3277d1) widened
  it to 11 retries ~= 60s. Record that 20s is the drain estimate and that the
  budget deliberately sits under #8697's shipped grace.
- daemon-init-wedged-daemon-grace.test.ts: pin the trade directly — a wedge
  draining after the budget is replaced and loses its live sessions.
- Rewrite 'preserves a daemon that stays wedged until the LAST allowed grace
  retry' onto the simulated clock. It never mocked Date.now, so its 12 probes
  elapsed ~0ms and asserted a retry grace the wall clock can no longer
  deliver; it now pins the last drain the budget still adopts.

* fix(daemon): name the socket probe default and correct the grace-retry rationale

Answers the review round on the budget accounting: the outer-catch endpoint
rescue is deliberately outside it, and the preflight clamp no longer duplicates
probeDaemonSocket's default as a bare literal.
2026-08-31 02:41:11 -07:00

112 lines
4.4 KiB
TypeScript

import { existsSync, mkdirSync } from 'node:fs'
import { connect } from 'node:net'
import { join } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { getDaemonLogFilePath } from '../observability/logs-directory'
import { DaemonClient } from './client'
import { daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget'
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
import { PROTOCOL_VERSION, type ListSessionsResult } from './types'
export function getDaemonRuntimeDir(): string {
const dir = join(getAppEnvironment().getPath('userData'), 'daemon')
mkdirSync(dir, { recursive: true })
return dir
}
export function getDaemonHistoryDir(): string {
const dir = join(getAppEnvironment().getPath('userData'), 'terminal-history')
mkdirSync(dir, { recursive: true })
return dir
}
export function getDaemonEntryPath(): string {
const appPath = getAppEnvironment().getAppPath()
// Why: packaged getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable.
// Why asar and not isPackaged: orcad is a packaged non-Electron host whose bundle root holds
// orcad.js and daemon-entry.js side by side with no asar to redirect (see parcel-watcher-entry-path.ts).
const basePath = appPath.includes('app.asar')
? appPath.replace('app.asar', 'app.asar.unpacked')
: appPath
const directEntryPath = join(basePath, 'daemon-entry.js')
return existsSync(directEntryPath)
? directEntryPath
: join(basePath, 'out', 'main', 'daemon-entry.js')
}
// macOS TCC attribution pins the daemon to a packaged app bundle; there is none on a Node host.
export function resolvePackagedDarwinAppVersion(): string | null {
const environment = getAppEnvironment()
return process.platform === 'darwin' && environment.isPackaged() ? environment.getVersion() : null
}
// Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch.
export function daemonLogArgs(): string[] {
const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase()
return disabled === '1' || disabled === 'true' ? [] : ['--log-file', getDaemonLogFilePath()]
}
/** Named so a caller clamping this probe to a deadline cannot silently decouple from its default. */
export const DAEMON_SOCKET_PROBE_TIMEOUT_MS = 1_000
// Why: a socket that accepts a connection proves a daemon survived a previous app session and can be reused.
export function probeDaemonSocket(
socketPath: string,
timeoutMs = DAEMON_SOCKET_PROBE_TIMEOUT_MS
): Promise<boolean> {
const { promise, resolve } = Promise.withResolvers<boolean>()
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve(false)
return promise
}
const socket = connect({ path: socketPath })
let settled = false
let timer: ReturnType<typeof setTimeout>
const finish = (alive: boolean, destroy = false): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.removeListener('connect', onConnect)
socket.removeListener('error', onError)
if (destroy) {
socket.destroy()
}
resolve(alive)
}
const onConnect = (): void => finish(true, true)
const onError = (): void => finish(false)
timer = setTimeout(() => finish(false, true), timeoutMs)
socket.on('connect', onConnect)
socket.on('error', onError)
return promise
}
// Why recoveryDeadlineMs is required: this probe only ever runs on a startup path that has a
// budget, and the client's own defaults are far larger than any of them.
export async function getAliveDaemonSessionCount(
socketPath: string,
tokenPath: string,
recoveryDeadlineMs: number,
protocolVersion = PROTOCOL_VERSION
): Promise<number | null> {
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
// Why one slice for both: a wedged handshake must not leave the request its own fresh 30s.
const probeTimeoutMs = daemonRecoveryProbeTimeoutMs(recoveryDeadlineMs)
const probeDeadlineMs = Date.now() + probeTimeoutMs
try {
await client.ensureConnectedWithin(probeTimeoutMs)
const result = await client.request<ListSessionsResult>(
'listSessions',
undefined,
remainingDaemonRequestTimeoutMs(probeDeadlineMs)
)
return result.sessions.filter((session) => session.isAlive).length
} catch {
return null
} finally {
client.disconnect()
}
}