orca/src/main/daemon/daemon-init.ts

1168 lines
46 KiB
TypeScript

/* eslint-disable max-lines -- Why: owns the full daemon lifecycle (init, launch, adapter wiring,
restart, teardown); the "swap the provider atomically" invariant keeps restart + singletons co-located. */
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { app } from 'electron'
import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { fork, type ChildProcess } from 'node:child_process'
import { connect } from 'node:net'
import {
DaemonSpawner,
getDaemonPidPath,
getDaemonSocketPath,
getDaemonTokenPath,
serializeDaemonPidFile,
unlinkOwnedDaemonPidFile,
type DaemonLauncher,
type DaemonProcessHandle
} from './daemon-spawner'
import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonClient } from './client'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
type ListSessionsResult
} from './types'
import {
getMacDaemonSystemResolverHealth,
getDaemonLaunchIdentity,
checkDaemonHealth,
isDaemonStaleForCurrentBundle,
killStaleDaemon,
parseDaemonPidFile
} from './daemon-health'
import {
collectPinnedDaemonVersions,
materializeRelocatedDaemonHost,
pruneOldDaemonHosts
} from './daemon-host-relocation'
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import { trackDaemonReplaced, trackDaemonRetired } from './daemon-lifecycle-event'
import type { DaemonReplaceReason } from '../../shared/daemon-lifecycle-telemetry'
import {
getLocalPtyProvider,
setLocalPtyProvider,
unbindLocalProviderListeners,
rebindLocalProviderListeners
} from '../ipc/pty'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics'
import { getDaemonLogFilePath } from '../observability/logs-directory'
import {
confirmSeededClaudeLivePtys,
hasSeededUnconfirmedClaudePtys
} from '../claude-accounts/live-pty-gate'
import { parseDaemonReadyIdentity } from './daemon-ready-identity'
// Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start.
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
if (isStartupDiagnosticsEnabled()) {
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
}
}
// Why: extra hello+listSessions probes (~5s each) giving a wedged-but-connectable daemon ~60s grace to answer and keep its live sessions before a permanent wedge (#8689) is replaced; raise only alongside the fail-open cap.
export const WEDGED_DAEMON_GRACE_RETRIES = 11
const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000
const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000
const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000
let spawner: DaemonSpawner | null = null
type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider
let adapter: DaemonProvider | null = null
// Why: coalesce concurrent restartDaemon() calls so two entries can't race the 7-step sequence against a half-spawned replacement.
let restartInFlight: Promise<RestartDaemonResult> | null = null
function getRuntimeDir(): string {
const dir = join(app.getPath('userData'), 'daemon')
mkdirSync(dir, { recursive: true })
return dir
}
function getHistoryDir(): string {
const dir = join(app.getPath('userData'), 'terminal-history')
mkdirSync(dir, { recursive: true })
return dir
}
function getDaemonEntryPath(): string {
const appPath = app.getAppPath()
// Why: packaged app.getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable.
const basePath = app.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
const directEntryPath = join(basePath, 'daemon-entry.js')
if (existsSync(directEntryPath)) {
return directEntryPath
}
return join(basePath, 'out', 'main', 'daemon-entry.js')
}
// Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch.
function daemonLogArgs(): string[] {
const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase()
if (disabled === '1' || disabled === 'true') {
return []
}
return ['--log-file', getDaemonLogFilePath()]
}
// Why: a socket that accepts a connection proves a daemon survived a previous app session and can be reused.
function probeSocket(socketPath: string): Promise<boolean> {
return new Promise((resolve) => {
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve(false)
return
}
const sock = connect({ path: socketPath })
let settled = false
let timer: ReturnType<typeof setTimeout>
function finish(alive: boolean, options?: { destroy?: boolean }): void {
if (settled) {
return
}
settled = true
clearTimeout(timer)
sock.removeListener('connect', onConnect)
sock.removeListener('error', onError)
if (options?.destroy) {
sock.destroy()
}
resolve(alive)
}
function onConnect(): void {
finish(true, { destroy: true })
}
function onError(): void {
finish(false)
}
timer = setTimeout(() => {
finish(false, { destroy: true })
}, 1000)
sock.on('connect', onConnect)
sock.on('error', onError)
})
}
async function getAliveDaemonSessionCount(
socketPath: string,
tokenPath: string,
protocolVersion = PROTOCOL_VERSION
): Promise<number | null> {
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
try {
await client.ensureConnected()
const result = await client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions.filter((session) => session.isAlive).length
} catch {
return null
} finally {
client.disconnect()
}
}
function createPreservedDaemonHandle(
runtimeDir: string,
protocolVersion = PROTOCOL_VERSION,
mode?: 'degraded-new-pty-fallback'
): DaemonProcessHandle {
const handle: DaemonProcessHandle = {
shutdown: async () => {
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
}
}
if (mode) {
handle.mode = mode
}
return handle
}
async function holdDaemonAdoptionLease(
handle: DaemonProcessHandle,
socketPath: string,
tokenPath: string,
connectedClient?: DaemonClient
): Promise<DaemonProcessHandle> {
const client = connectedClient ?? new DaemonClient({ socketPath, tokenPath })
try {
await client.ensureConnected()
} catch (error) {
client.disconnect()
throw error
}
handle.releaseAdoptionLease = () => client.disconnect()
return handle
}
function releaseDaemonAdoptionLease(handle: DaemonProcessHandle | null): void {
takeDaemonAdoptionLeaseRelease(handle)?.()
}
function takeDaemonAdoptionLeaseRelease(
handle: DaemonProcessHandle | null
): (() => void) | undefined {
const release = handle?.releaseAdoptionLease
if (!release || !handle) {
return undefined
}
delete handle.releaseAdoptionLease
return release
}
async function cleanupFailedDaemonAdoption(
failedSpawner: DaemonSpawner,
current: DaemonPtyAdapter,
legacy: DaemonPtyAdapter[] = []
): Promise<void> {
const handle = failedSpawner.getHandle()
const results = await Promise.allSettled([
Promise.resolve().then(() => releaseDaemonAdoptionLease(handle)),
...legacy.map((entry) => entry.disconnectOnly()),
(async () => {
try {
// Why: other authenticated clients may win, so only daemon-side shutdownIfIdle can prove a failed adoption is killable.
await current.disconnectOnly()
} catch (error) {
current.dispose()
throw error
}
})()
])
const failures = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (failures.length > 0) {
throw new AggregateError(failures, 'Daemon adoption cleanup failed')
}
}
async function terminateLaunchedDaemonChild(child: ChildProcess): Promise<void> {
try {
if (
(child.exitCode !== null && child.exitCode !== undefined) ||
(child.signalCode !== null && child.signalCode !== undefined)
) {
return
}
await new Promise<void>((resolve, reject) => {
let gracefulTimer: ReturnType<typeof setTimeout>
let forcedTimer: ReturnType<typeof setTimeout> | undefined
let settled = false
const finish = (error?: unknown): void => {
if (settled) {
return
}
settled = true
clearTimeout(gracefulTimer)
if (forcedTimer) {
clearTimeout(forcedTimer)
}
child.off('exit', onExit)
if (error) {
reject(error)
} else {
resolve()
}
}
const onExit = (): void => finish()
child.on('exit', onExit)
gracefulTimer = setTimeout(() => {
if (child.pid) {
try {
process.kill(child.pid, 'SIGKILL')
} catch (error) {
finish(isNoSuchProcessError(error) ? undefined : error)
return
}
}
if (!settled) {
forcedTimer = setTimeout(
() => finish(new Error('Daemon did not exit after SIGKILL')),
DAEMON_CHILD_FORCE_EXIT_WAIT_MS
)
}
}, DAEMON_CHILD_TERMINATION_GRACE_MS)
if (child.pid) {
try {
process.kill(child.pid, 'SIGTERM')
} catch (error) {
finish(isNoSuchProcessError(error) ? undefined : error)
}
} else {
finish()
}
})
} finally {
if (child.connected) {
child.disconnect()
}
child.unref()
}
}
function isNoSuchProcessError(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH'
}
async function shouldPreserveDaemonWithLiveSessions(
socketPath: string,
tokenPath: string,
replacementLabel: string
): Promise<boolean> {
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount === 0) {
return false
}
console.warn(
liveSessionCount === null
? `[daemon] Preserving daemon ${replacementLabel} because live session state could not be verified`
: `[daemon] Preserving daemon ${replacementLabel} because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return true
}
// Why: the adapter decides a runtime resolver replacement, but the launcher completes it — and by
// then the daemon has usually self-retired (dropping its last authenticated client is enough), so
// there is nothing left to kill and the launcher's own confirmed-kill gate would report nothing.
// The adapter hands the reason across so the launch it triggers reports what actually drove it.
let attributedReplaceReason: DaemonReplaceReason | null = null
function createOutOfProcessLauncher(
runtimeDir: string,
macosLoginSessionWatch = false
): DaemonLauncher {
return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => {
const entryPath = getDaemonEntryPath()
const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir)
const launchNonce = suppliedLaunchNonce ?? randomUUID()
// One-shot: whichever launch consumes it owns the attribution, so a later unrelated launch can't
// reuse it. The write in the respawn closure reaches here without an intervening await, which is
// what makes a bare module-scoped slot safe — keep it that way or a concurrent launch can steal it.
const attributedReason = attributedReplaceReason
attributedReplaceReason = null
let pendingReplacement:
| {
reason: Parameters<typeof trackDaemonReplaced>[0]
liveSessionCount: number | null
}
| undefined
let confirmedReplacement = false
let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath })
try {
// Why: acquire the full pair before control-only probes so an expired inherited deadline can't fire in the probe-to-adoption gap.
await adoptionClient.ensureConnected()
} catch {
adoptionClient.disconnect()
adoptionClient = null
}
const preserveDaemon = async (
mode?: 'degraded-new-pty-fallback'
): Promise<DaemonProcessHandle> => {
const connectedClient = adoptionClient ?? undefined
adoptionClient = null
return holdDaemonAdoptionLease(
createPreservedDaemonHandle(runtimeDir, PROTOCOL_VERSION, mode),
socketPath,
tokenPath,
connectedClient
)
}
try {
const health = await checkDaemonHealth(socketPath, tokenPath)
if (health === 'healthy') {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return preserveDaemon()
}
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
pendingReplacement = { reason: 'unhealthy_resolver', liveSessionCount }
confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION))
.cleaned
} else {
// Why: a protocol-healthy daemon can outlive its launching app bundle (dev worktree rebuild, or packaged update replacing the app path).
const identity = await getDaemonLaunchIdentity(
runtimeDir,
socketPath,
tokenPath,
entryPath
)
const stalePackagedBundle =
app.isPackaged &&
(await isDaemonStaleForCurrentBundle(
runtimeDir,
socketPath,
tokenPath,
app.getVersion()
))
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code freshness until no live sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (
await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)
) {
return preserveDaemon()
}
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
// liveSessionCount is 0: shouldPreserveDaemonWithLiveSessions() only falls through at exactly 0.
pendingReplacement = {
reason: stalePackagedBundle ? 'stale_bundle' : 'different_app_path',
liveSessionCount: 0
}
confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION))
.cleaned
} else {
// Why: healthy daemon from a previous session answered a protocol ping — safe to reuse.
return preserveDaemon()
}
}
} else {
// Why: a busy machine can time out the health check on a live daemon; re-verify with a session list before killing its sessions.
let liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
// Why: a wedged-but-connectable daemon (Windows update relaunch) may still own live sessions, so grace-retry before replacing; a permanent wedge (#8689) exhausts the grace, and 'rejected' skips it (handshake refused = never adoptable).
let graceRetry = 0
while (
liveSessionCount === null &&
health !== 'rejected' &&
graceRetry < WEDGED_DAEMON_GRACE_RETRIES &&
(await probeSocket(socketPath))
) {
liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
graceRetry++
}
if (liveSessionCount !== null && liveSessionCount > 0) {
if (health === 'pty-spawn-unhealthy') {
console.warn(
`[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).`
)
return preserveDaemon('degraded-new-pty-fallback')
}
console.warn(
`[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return preserveDaemon()
}
// Why: the sibling replace branches announce themselves, but this one used
// to kill a daemon silently — leaving no way to tell a replacement apart
// from an adoption after the fact. A cold start also lands here with
// nothing to replace, so only speak up once something actually answered:
// a probe that returned a count, a socket that survived a grace retry, or
// a refused hello.
if (liveSessionCount !== null || graceRetry > 0 || health === 'rejected') {
console.warn(
`[daemon] Replacing daemon that failed the health check (health=${health}, liveSessions=${liveSessionCount ?? 'unverifiable'}, graceRetries=${graceRetry})`
)
}
// Why: unlike the log above, telemetry gates on confirmedReplacement below — the
// post-kill truth — so a cold start that killed nothing never reports a replacement.
pendingReplacement = { reason: 'failed_health_check', liveSessionCount }
}
// Why: a raw socket can outlive a broken daemon; kill by PID before respawn so the new daemon doesn't race the stale one.
adoptionClient?.disconnect()
adoptionClient = null
confirmedReplacement =
(await killStaleDaemon(runtimeDir, socketPath, tokenPath)) || confirmedReplacement
// Why: rank by how well each reason is evidenced. A confirmed kill whose reason positively
// identified the daemon outranks the attribution, so a stale bundle caught here is not billed
// to the resolver. failed_health_check is the residual "couldn't tell" bucket though — it also
// absorbs wedges and crashes — so the adapter's attribution beats it. That case is not exotic:
// the same dead login session that fails the resolver also fails the PTY spawn probe, and with
// zero live sessions that lands here rather than in the degraded preserve above.
const identifiedReplacement =
pendingReplacement &&
confirmedReplacement &&
pendingReplacement.reason !== 'failed_health_check'
? pendingReplacement
: null
if (identifiedReplacement) {
trackDaemonReplaced(identifiedReplacement.reason, identifiedReplacement.liveSessionCount)
} else if (attributedReason) {
trackDaemonReplaced(attributedReason, 0)
} else if (pendingReplacement && confirmedReplacement) {
trackDaemonReplaced(pendingReplacement.reason, pendingReplacement.liveSessionCount)
}
const userDataPath = app.getPath('userData')
// Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host.
const relocatedHost = materializeRelocatedDaemonHost()
// Fork the relocated entry when available; otherwise the install-dir entry.
const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath
const child = fork(
forkEntryPath,
[
'--socket',
socketPath,
'--token',
tokenPath,
'--pid-record',
pidPath,
'--launch-nonce',
launchNonce,
...(macosLoginSessionWatch ? ['--login-session-watch'] : []),
...daemonLogArgs()
],
{
// Why: detached daemons outlive dev worktrees; userData keeps process.cwd() valid after a repo/worktree is deleted.
cwd: userDataPath,
// Why: detached+unref outlives Electron; stdout 'ignore' (else blocks exit), stderr 'pipe' captures startup crashes lost in v1.4.129-rc.1.
detached: true,
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
// Why: run the byte-identical relocated Orca.exe so the image path sits outside the updater's kill zone.
...(relocatedHost ? { execPath: relocatedHost.execPath } : {}),
// Why: run the fork as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper.
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
// Why: the detached plain-Node daemon can't call app.getPath(), but shell rcfiles must live outside swept tmp.
ORCA_USER_DATA_PATH: userDataPath
}
}
)
// Why: keep only the startup-window stderr tail so a crash cause is visible without unbounded memory.
const STARTUP_STDERR_MAX_BYTES = 8192
let startupStderr = ''
let collectingStderr = true
const onStartupStderr = (chunk: Buffer): void => {
if (!collectingStderr) {
return
}
startupStderr += chunk.toString('utf8')
if (startupStderr.length > STARTUP_STDERR_MAX_BYTES) {
startupStderr = startupStderr.slice(-STARTUP_STDERR_MAX_BYTES)
}
}
child.stderr?.on('data', onStartupStderr)
// Why: release the detached daemon's stderr once up/failed — a live piped stream refs the parent loop and blocks Electron exit.
const releaseStderr = (): void => {
collectingStderr = false
child.stderr?.off('data', onStartupStderr)
child.stderr?.destroy()
}
// Wait for the daemon to signal readiness via IPC
await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
function cleanupStartupListeners(): void {
if (timer) {
clearTimeout(timer)
}
child.off('message', onReadyMessage)
child.off('error', onStartupError)
child.off('exit', onStartupExit)
}
async function fail(error: Error): Promise<void> {
if (settled) {
return
}
settled = true
cleanupStartupListeners()
// Why: attach the captured stderr tail to the thrown error and log it so a startup crash isn't just "exited with code 1".
const stderrTail = startupStderr.trim()
if (stderrTail) {
console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`)
}
releaseStderr()
const startupError = stderrTail
? new Error(`${error.message}\nDaemon stderr (tail):\n${stderrTail}`)
: error
try {
await terminateLaunchedDaemonChild(child)
} catch (cleanupError) {
reject(
new AggregateError(
[startupError, cleanupError],
'Daemon startup and child cleanup both failed'
)
)
return
}
reject(startupError)
}
function onReadyMessage(msg: unknown): void {
if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') {
if (settled) {
return
}
const readyIdentity = parseDaemonReadyIdentity(msg)
if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0 || !readyIdentity) {
void fail(new Error('Daemon readiness identity is incomplete'))
return
}
try {
// Why: pid record shares the daemon's self time and nonce so cleanup can identify this exact process incarnation.
writeFileSync(
pidPath,
serializeDaemonPidFile({
pid: child.pid as number,
...readyIdentity,
entryPath,
appVersion: app.getVersion(),
launchNonce
}),
{ mode: 0o600, flag: 'wx' }
)
} catch (error) {
void fail(error instanceof Error ? error : new Error(String(error)))
return
}
settled = true
// Why: daemon is detached after readiness; detach startup listeners so the launch promise closure isn't retained.
cleanupStartupListeners()
// Why: release IPC/stderr and unref so Electron can exit without waiting; the daemon keeps running detached.
releaseStderr()
child.disconnect()
child.unref()
resolve()
}
}
function onStartupError(err: Error): void {
void fail(err)
}
function onStartupExit(code: number | null): void {
void fail(new Error(`Daemon exited during startup with code ${code}`))
}
timer = setTimeout(() => {
void fail(new Error('Daemon startup timed out'))
}, 10000)
child.on('message', onReadyMessage)
child.on('error', onStartupError)
child.on('exit', onStartupExit)
})
try {
return await holdDaemonAdoptionLease(
{
shutdown: () => terminateLaunchedDaemonChild(child)
},
socketPath,
tokenPath
)
} catch (error) {
// Why: another client may have adopted this live process; keep its pid record until exit, but remove one published after an early exit.
let pidRecordRemoved = false
const removeExitedPidRecord = (): void => {
if (pidRecordRemoved) {
return
}
pidRecordRemoved = true
unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce)
}
child.once('exit', removeExitedPidRecord)
if (
(child.exitCode !== null && child.exitCode !== undefined) ||
(child.signalCode !== null && child.signalCode !== undefined)
) {
child.off('exit', removeExitedPidRecord)
removeExitedPidRecord()
}
throw error
}
} catch (error) {
adoptionClient?.disconnect()
throw error
}
}
}
export async function initDaemonPtyProvider(
signal?: AbortSignal,
options: { macosLoginSessionWatch?: boolean } = {}
): Promise<void> {
logDaemonMilestone('daemon-init-start')
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
if (Number.isFinite(e2eInitDelayMs) && e2eInitDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, e2eInitDelayMs))
}
const runtimeDir = getRuntimeDir()
const newSpawner = new DaemonSpawner({
runtimeDir,
launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false)
})
// Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner.
const info = await newSpawner.ensureRunning()
// Why: reclaim superseded daemon-host copies on EVERY launch (spawns are rare), keeping current + live-daemon-pinned versions.
pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir))
const launchMode = newSpawner.getHandle()?.mode
logDaemonMilestone('daemon-current-ready')
if (signal?.aborted) {
// Why: fail-open may already have spawned fallback PTYs; don't install late, but retire an empty daemon (live sessions reject it and survive).
const abortedStartupAdapter = new DaemonPtyAdapter({
socketPath: info.socketPath,
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir
})
releaseDaemonAdoptionLease(newSpawner.getHandle())
await abortedStartupAdapter.disconnectOnly()
return
}
const newAdapter = new DaemonPtyAdapter({
socketPath: info.socketPath,
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir,
historyPath: getHistoryDir(),
// Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying.
respawn: async (reason: DaemonRespawnReason) => {
// Why: attribute rather than emit — the launcher below is the one that completes the
// replacement, and emitting here would fire before the outcome is known.
// Caveat: a wedged-but-alive daemon (#8689) can still report died_respawn here and
// failed_health_check from the launcher — the app cannot tell wedged from dead at this point.
if (reason === 'daemon_died') {
console.warn('[daemon] Daemon process died — respawning')
// Why: a manual restart tears the daemon down under a still-live adapter, so a pane
// respawning on its synthetic exit would bill a user action to the crash bucket.
if (!restartInFlight) {
trackDaemonRetired('died_respawn')
}
} else if (reason === 'unhealthy_resolver') {
// Must reach the launcher below without an await in between; see the consume site.
attributedReplaceReason = 'unhealthy_resolver'
}
newSpawner.resetHandle()
await newSpawner.ensureRunning()
return takeDaemonAdoptionLeaseRelease(newSpawner.getHandle())
}
})
let legacyAdapters: DaemonPtyAdapter[] = []
let routedAdapter: DaemonProvider = newAdapter
try {
// Why: the launcher's temporary pair closes only after this permanent pair is established, leaving no adoption gap.
await newAdapter.establishLifecycleLease()
releaseDaemonAdoptionLease(newSpawner.getHandle())
legacyAdapters = await createLegacyDaemonAdapters(runtimeDir)
routedAdapter =
launchMode === 'degraded-new-pty-fallback'
? new DegradedDaemonPtyProvider({
current: newAdapter,
legacy: legacyAdapters,
fallback: getLocalPtyProvider()
})
: legacyAdapters.length > 0
? new DaemonPtyRouter({
current: newAdapter,
legacy: legacyAdapters
})
: newAdapter
if (routedAdapter instanceof DegradedDaemonPtyProvider) {
// Why: preserved daemon can't create fresh terminals; discover its live session ids so only they route to it (fresh panes fall back locally).
await routedAdapter.discoverDaemonSessions()
} else if (routedAdapter instanceof DaemonPtyRouter) {
await routedAdapter.discoverLegacySessions()
}
if (signal?.aborted) {
// Why: same late-swap guard after legacy discovery; release uninstalled adapter leases without killing live sessions.
await routedAdapter.disconnectOnly()
return
}
} catch (error) {
try {
await cleanupFailedDaemonAdoption(newSpawner, newAdapter, legacyAdapters)
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'Daemon adoption and cleanup both failed')
}
throw error
}
spawner = newSpawner
adapter = routedAdapter
setLocalPtyProvider(routedAdapter)
// Why: the first window may register PTY listeners before daemon init finishes; rebind so daemon PTYs still fan out events.
rebindLocalProviderListeners()
logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length })
await reconcileSeededClaudeLivePtys(routedAdapter)
}
// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token.
async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise<void> {
if (!hasSeededUnconfirmedClaudePtys()) {
return
}
try {
const adapters =
provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider
? provider.getAllAdapters()
: [provider]
const results = await Promise.allSettled(adapters.map((entry) => entry.listSessions()))
if (results.some((result) => result.status === 'rejected')) {
console.warn('[daemon] Keeping seeded Claude live-PTY gate — session listing failed')
return
}
confirmSeededClaudeLivePtys(
results.flatMap((result) =>
result.status === 'fulfilled' ? result.value.map((session) => session.sessionId) : []
)
)
} catch (error) {
// Why: gate bookkeeping must never fail daemon init; stale seeds only defer a usage refresh until next restart.
console.warn('[daemon] Failed to reconcile seeded Claude live-PTY gate:', error)
}
}
// Why: a narrow getter (not a raw export) keeps the "swap on restart" invariant in one place (replaceDaemonProvider).
export function getDaemonProvider(): DaemonProvider | null {
return adapter
}
// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference.
export function replaceDaemonProvider(newAdapter: DaemonProvider): void {
adapter = newAdapter
setLocalPtyProvider(newAdapter)
}
function getCurrentDaemonAdapter(provider: DaemonProvider): DaemonPtyAdapter {
if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) {
return provider.getCurrentAdapter()
}
return provider
}
function getLegacyDaemonAdapters(provider: DaemonProvider): DaemonPtyAdapter[] {
if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) {
return [...provider.getLegacyAdapters()]
}
return []
}
function disposeProviderSubscriptionsOnly(provider: DaemonProvider): void {
if (provider instanceof DaemonPtyRouter) {
provider.disposeRouterOnly()
return
}
if (provider instanceof DegradedDaemonPtyProvider) {
provider.disposeProviderOnly()
}
}
export type RestartDaemonResult = {
killedCount: number
}
// Why: the 7-step restart sequence from docs/daemon-staleness-ux.md §Phase 1; current-protocol only (legacy adapters preserved).
export async function restartDaemon(): Promise<RestartDaemonResult> {
if (restartInFlight) {
return restartInFlight
}
restartInFlight = runRestartDaemon().finally(() => {
restartInFlight = null
})
return restartInFlight
}
async function runRestartDaemon(): Promise<RestartDaemonResult> {
const currentSpawner = spawner
const currentAdapter = adapter
if (!currentSpawner || !currentAdapter) {
throw new Error('restartDaemon called before initDaemonPtyProvider')
}
const runtimeDir = getRuntimeDir()
const currentOnly = getCurrentDaemonAdapter(currentAdapter)
const legacyAdapters = getLegacyDaemonAdapters(currentAdapter)
// Step 1: synthesize pty:exit for every active session BEFORE teardown — the daemon's shutdown path never fans onExit to clients (session.ts:246-252), so the renderer would otherwise never see exits.
const fallbackKilledCount =
currentAdapter instanceof DegradedDaemonPtyProvider
? await currentAdapter.shutdownFallbackSessions()
: 0
const currentDaemonSessionIds =
currentAdapter instanceof DegradedDaemonPtyProvider
? currentAdapter.getCurrentDaemonSessionIds()
: []
const killedCount =
new Set([...currentOnly.getActiveSessionIds(), ...currentDaemonSessionIds]).size +
fallbackKilledCount
currentOnly.fanoutSyntheticExits(-1)
if (currentAdapter instanceof DegradedDaemonPtyProvider) {
currentAdapter.fanoutCurrentDaemonSyntheticExits(-1)
}
// Step 2: detach renderer listeners — after step 1 (so synthesized exits land) and before step 6 (no stale binding).
unbindLocalProviderListeners()
// Step 3: kill the current-protocol daemon process; legacy adapters untouched.
let info: Awaited<ReturnType<DaemonSpawner['ensureRunning']>>
try {
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
// Step 4: reuse the existing spawner so the respawn closure baked into long-lived adapters stays valid (do NOT new one).
currentSpawner.resetHandle()
info = await currentSpawner.ensureRunning()
} catch (error) {
// Why: old provider stays authoritative until the final swap; rebind since relaunch failed after teardown.
rebindLocalProviderListeners()
throw error
}
// Step 5: build a fresh current adapter against the respawned daemon.
const newCurrent = new DaemonPtyAdapter({
socketPath: info.socketPath,
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir,
historyPath: getHistoryDir(),
respawn: async (reason: DaemonRespawnReason) => {
// Why: attribute rather than emit — the launcher below is the one that completes the
// replacement, and emitting here would fire before the outcome is known.
// Caveat: a wedged-but-alive daemon (#8689) can still report died_respawn here and
// failed_health_check from the launcher — the app cannot tell wedged from dead at this point.
if (reason === 'daemon_died') {
console.warn('[daemon] Daemon process died — respawning')
// Why: a manual restart tears the daemon down under a still-live adapter, so a pane
// respawning on its synthetic exit would bill a user action to the crash bucket.
if (!restartInFlight) {
trackDaemonRetired('died_respawn')
}
} else if (reason === 'unhealthy_resolver') {
// Must reach the launcher below without an await in between; see the consume site.
attributedReplaceReason = 'unhealthy_resolver'
}
currentSpawner.resetHandle()
await currentSpawner.ensureRunning()
return takeDaemonAdoptionLeaseRelease(currentSpawner.getHandle())
}
})
let newProvider: DaemonProvider = newCurrent
try {
// Temporary launcher lease overlaps this permanent pair so a manual restart can't strand a newly spawned daemon during adoption.
await newCurrent.establishLifecycleLease()
releaseDaemonAdoptionLease(currentSpawner.getHandle())
// Re-wrap in a router only if legacy adapters exist; they're preserved by reference and still route to their pre-upgrade daemons.
newProvider =
legacyAdapters.length > 0
? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters })
: newCurrent
if (newProvider instanceof DaemonPtyRouter) {
await newProvider.discoverLegacySessions()
}
} catch (error) {
let cleanupError: unknown
try {
if (newProvider instanceof DaemonPtyRouter) {
newProvider.disposeRouterOnly()
}
await cleanupFailedDaemonAdoption(currentSpawner, newCurrent)
} catch (caught) {
cleanupError = caught
}
// Previous provider stays module-authoritative until the swap; restore its renderer bindings when adoption fails.
rebindLocalProviderListeners()
if (cleanupError) {
throw new AggregateError([error, cleanupError], 'Daemon restart and cleanup both failed')
}
throw error
}
// Drain the old router's subscriptions via the router-only variant (plain dispose() would tear down the shared legacy adapters), after the new provider exists (no unhandled events) and before the swap (atomic for the renderer).
disposeProviderSubscriptionsOnly(currentAdapter)
// Step 6: swap module state (adapter + localProvider) atomically.
replaceDaemonProvider(newProvider)
// Step 7: rebind renderer listeners against the new provider.
rebindLocalProviderListeners()
return { killedCount }
}
// Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach.
// Leave history sessions marked "unclean" so a daemon crash while Orca is closed stays recoverable.
export async function disconnectDaemon(): Promise<void> {
await adapter?.disconnectOnly()
adapter = null
}
/** Kill the daemon and all its sessions. Use for full cleanup only. */
export async function shutdownDaemon(): Promise<void> {
adapter?.dispose()
adapter = null
await spawner?.shutdown()
spawner = null
}
export type OrphanedDaemonCleanupResult = {
/** True when a live daemon socket was found and torn down; false when none was running. */
cleaned: boolean
/** Number of live PTY sessions killed during cleanup (surfaced to the user). */
killedCount: number
}
export async function cleanupDaemonForProtocol(
runtimeDir: string,
protocolVersion: number
): Promise<OrphanedDaemonCleanupResult> {
const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion)
const tokenPath = getDaemonTokenPath(runtimeDir, protocolVersion)
const pidPath = getDaemonPidPath(runtimeDir, protocolVersion)
const alive = await probeSocket(socketPath)
if (!alive) {
if (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
// Endpoint absence doesn't prove the PID record belongs to the current protocol; leave artifact cleanup to the owning daemon.
return { cleaned: false, killedCount: 0 }
}
// Best-effort remove a stale socket so a future launch doesn't hit EADDRINUSE on bind.
if (process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
} catch {
// Best-effort
}
}
try {
unlinkSync(pidPath)
} catch {
// Best-effort
}
return { cleaned: false, killedCount: 0 }
}
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
let killedCount = 0
let didRequestShutdown = false
let didKillStaleDaemon = false
try {
await client.ensureConnected()
const sessions = await client
.request<ListSessionsResult>('listSessions', undefined)
.catch(() => ({ sessions: [] }))
killedCount = sessions.sessions.filter((s) => s.isAlive).length
// Use the single-shot `shutdown` RPC (kills all sessions then exits) to avoid racing per-session `kill` calls against the daemon exiting.
await client.request('shutdown', { killSessions: true }).catch(() => {
// Daemon exits immediately after the RPC, so the socket may close before the reply arrives; treat as success.
})
didRequestShutdown = true
} catch {
// Previous-protocol daemons may be wedged or too old for the RPC path; fall back to PID cleanup (only unlinks a live socket after proving the process is killed).
didKillStaleDaemon = await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion)
} finally {
client.disconnect()
}
if (didRequestShutdown && protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
if (!(await waitForDaemonEndpointExit(socketPath))) {
// Never fork a replacement while the old incarnation may still own the endpoint or be disposing terminal children.
throw new Error('Timed out waiting for daemon self-shutdown')
}
return { cleaned: true, killedCount }
}
// Defensively unlink the socket: the daemon normally removes it after `shutdown`, but on some crash paths it lingers and blocks a later rebind.
if (didRequestShutdown && process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
} catch {
// Best-effort
}
}
try {
unlinkSync(pidPath)
} catch {
// Best-effort
}
return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount }
}
async function waitForDaemonEndpointExit(socketPath: string): Promise<boolean> {
const deadline = Date.now() + DAEMON_SELF_SHUTDOWN_WAIT_MS
while (Date.now() < deadline) {
if (!(await probeSocket(socketPath))) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
return !(await probeSocket(socketPath))
}
function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean {
try {
const parsed = parseDaemonPidFile(
readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8')
)
if (!parsed) {
return false
}
process.kill(parsed.pid, 0)
return true
} catch {
return false
}
}
// Why: callers that own an isolated runtime namespace must keep discovery history out of app userData.
export async function createLegacyDaemonAdapters(
runtimeDir: string,
historyPath = getHistoryDir()
): Promise<DaemonPtyAdapter[]> {
const adapters: DaemonPtyAdapter[] = []
for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion)
const tokenPath = getDaemonTokenPath(runtimeDir, protocolVersion)
if (!(await probeSocket(socketPath))) {
// Why: a recycled stale pid later turns an identity check into a PowerShell spawn, so delete leaked pid/token files — but only when the pid-process is provably gone (a live daemon can transiently fail the probe, and dropping its token makes its sessions permanently unadoptable).
if (!legacyDaemonProcessMayBeAlive(runtimeDir, protocolVersion)) {
for (const stalePath of [
getDaemonPidPath(runtimeDir, protocolVersion),
getDaemonTokenPath(runtimeDir, protocolVersion)
]) {
try {
unlinkSync(stalePath)
} catch {
// Best-effort
}
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
} catch {
// Best-effort
}
}
}
continue
}
// Keep old-protocol PTYs routed to their original daemon during upgrade; legacy adapters never respawn (new code would recreate stale env semantics).
// historyPath is still needed for cleanup — without it a later v4 session reusing the same ID could false-restore stale scrollback.bin.
adapters.push(
new DaemonPtyAdapter({
socketPath,
tokenPath,
pidPath: getDaemonPidPath(runtimeDir, protocolVersion),
profileScope: runtimeDir,
protocolVersion,
historyPath
})
)
}
return adapters
}