mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
690 lines
26 KiB
TypeScript
690 lines
26 KiB
TypeScript
/* eslint-disable max-lines -- Why: this module owns the complete daemon
|
|
lifecycle for the Electron main process — init, out-of-process launch,
|
|
current+legacy adapter wiring, restart orchestration (the 7-step sequence
|
|
from docs/daemon-staleness-ux.md §Phase 1), and teardown on app quit. Splitting
|
|
it would scatter the "swap the running provider atomically" invariant across
|
|
files with no cleaner ownership seam: restart, replaceDaemonProvider, and the
|
|
module-level spawner/adapter singletons must stay co-located so a future
|
|
change cannot leave them drifting out of sync. */
|
|
import { join } from 'path'
|
|
import { app } from 'electron'
|
|
import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'fs'
|
|
import { fork } from 'child_process'
|
|
import { connect } from 'net'
|
|
import {
|
|
DaemonSpawner,
|
|
getDaemonPidPath,
|
|
getDaemonSocketPath,
|
|
getDaemonTokenPath,
|
|
serializeDaemonPidFile,
|
|
type DaemonLauncher
|
|
} from './daemon-spawner'
|
|
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
|
import { DaemonPtyRouter } from './daemon-pty-router'
|
|
import { DaemonClient } from './client'
|
|
import {
|
|
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
|
|
PROTOCOL_VERSION,
|
|
type ListSessionsResult
|
|
} from './types'
|
|
import {
|
|
getMacDaemonSystemResolverHealth,
|
|
getDaemonLaunchIdentity,
|
|
getProcessStartedAtMs,
|
|
healthCheckDaemon,
|
|
isDaemonStaleForCurrentBundle,
|
|
killStaleDaemon
|
|
} from './daemon-health'
|
|
import {
|
|
setLocalPtyProvider,
|
|
unbindLocalProviderListeners,
|
|
rebindLocalProviderListeners
|
|
} from '../ipc/pty'
|
|
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics'
|
|
|
|
// Why: daemon init runs concurrently with window load, so harness-side stderr
|
|
// arrival times are useless — in-process `t` lets the startup benchmark derive
|
|
// how long the daemon cold-start path actually took.
|
|
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
|
|
if (isStartupDiagnosticsEnabled()) {
|
|
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
|
|
}
|
|
}
|
|
|
|
let spawner: DaemonSpawner | null = null
|
|
let adapter: DaemonPtyRouter | DaemonPtyAdapter | null = null
|
|
// Why: coalesce concurrent restartDaemon() calls so two clicks (or a UI
|
|
// click racing an internal caller) can't both enter the 7-step sequence —
|
|
// the second entry would read the already-disposed current adapter and
|
|
// race cleanupDaemonForProtocol 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: electron-builder unpacks daemon-entry.js so child_process.fork() can
|
|
// execute it from disk. In packaged apps app.getAppPath() points at
|
|
// app.asar, so redirect to the unpacked sibling before joining the script.
|
|
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: before spawning a new daemon, check if an existing one is alive by
|
|
// attempting a TCP connection to the socket. If it connects, the daemon
|
|
// survived from a previous app session — reuse it instead of spawning.
|
|
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
|
|
): { shutdown(): Promise<void> } {
|
|
return {
|
|
shutdown: async () => {
|
|
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
|
return async (socketPath, tokenPath) => {
|
|
const entryPath = getDaemonEntryPath()
|
|
const healthy = await healthCheckDaemon(socketPath, tokenPath)
|
|
if (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 createPreservedDaemonHandle(runtimeDir)
|
|
}
|
|
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
|
|
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
|
|
} else {
|
|
// Why: a protocol-healthy daemon can outlive the app bundle that
|
|
// launched it. In dev this happens after deleting/rebuilding a
|
|
// worktree; in packaged apps it happens when the stable
|
|
// /Applications/Orca.app path is replaced during update.
|
|
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 terminal 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 createPreservedDaemonHandle(runtimeDir)
|
|
}
|
|
console.warn(
|
|
stalePackagedBundle
|
|
? '[daemon] Replacing daemon launched before the current app bundle was installed'
|
|
: '[daemon] Replacing daemon launched from a different app path'
|
|
)
|
|
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
|
|
} else {
|
|
// Why: daemon is already running from a previous app session and
|
|
// responded to a protocol-level ping. Safe to reuse.
|
|
return createPreservedDaemonHandle(runtimeDir)
|
|
}
|
|
}
|
|
} else {
|
|
// Why: a busy machine (e.g. right after an update) can time out the
|
|
// health check while the daemon is alive and owning terminals. Killing
|
|
// it would destroy every live session, so re-verify with a session list
|
|
// first. Only a verified non-empty list preserves: a daemon that cannot
|
|
// even list sessions cannot serve terminals, and replacing it is the
|
|
// only recovery.
|
|
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
|
|
if (liveSessionCount !== null && liveSessionCount > 0) {
|
|
console.warn(
|
|
`[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
|
|
)
|
|
return createPreservedDaemonHandle(runtimeDir)
|
|
}
|
|
}
|
|
|
|
// Why: a raw socket can outlive a broken or wedged daemon. Kill by PID
|
|
// before respawn so the new daemon does not race the stale process.
|
|
await killStaleDaemon(runtimeDir, socketPath, tokenPath)
|
|
|
|
const userDataPath = app.getPath('userData')
|
|
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
|
|
// Why: detached daemons can outlive dev worktrees. Starting from
|
|
// userData keeps process.cwd() valid after a repo/worktree is deleted.
|
|
cwd: userDataPath,
|
|
// Why: detached + unref lets the daemon outlive the Electron process.
|
|
// stdio 'ignore' prevents the child from holding the parent's stdout
|
|
// open, which would prevent Electron from exiting cleanly.
|
|
detached: true,
|
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
// Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain
|
|
// Node.js process instead of an Electron renderer/main process. Without
|
|
// it, Electron's GPU/display initialization can interfere with native
|
|
// module operations like node-pty's posix_spawn of the spawn-helper.
|
|
env: {
|
|
...process.env,
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
// Why: the detached daemon is plain Node and cannot call Electron's
|
|
// app.getPath(), but shell-ready rcfiles must live outside swept tmp.
|
|
ORCA_USER_DATA_PATH: userDataPath
|
|
}
|
|
})
|
|
|
|
// 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)
|
|
}
|
|
function fail(error: Error): void {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanupStartupListeners()
|
|
if (child.pid) {
|
|
try {
|
|
process.kill(child.pid, 'SIGTERM')
|
|
} catch {
|
|
// Already dead
|
|
}
|
|
}
|
|
reject(error)
|
|
}
|
|
function onReadyMessage(msg: unknown): void {
|
|
if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
// Why: the daemon process is detached after readiness; leaving
|
|
// startup listeners attached retains this launch promise closure.
|
|
cleanupStartupListeners()
|
|
if (child.pid) {
|
|
// Why: JSON pid file carries pid + process start time so later
|
|
// killStaleDaemon() can verify the pid still belongs to the daemon
|
|
// we forked before SIGTERMing it. Prevents pid-recycling hazard
|
|
// where the OS hands the daemon's old pid to an unrelated process.
|
|
writeFileSync(
|
|
getDaemonPidPath(runtimeDir),
|
|
serializeDaemonPidFile({
|
|
pid: child.pid,
|
|
startedAtMs: getProcessStartedAtMs(child.pid),
|
|
entryPath,
|
|
appVersion: app.getVersion()
|
|
}),
|
|
{ mode: 0o600 }
|
|
)
|
|
}
|
|
// Why: disconnect IPC channel and unref so Electron can exit
|
|
// without waiting for the daemon. The daemon keeps running.
|
|
child.disconnect()
|
|
child.unref()
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
function onStartupError(err: Error): void {
|
|
fail(err)
|
|
}
|
|
|
|
function onStartupExit(code: number | null): void {
|
|
fail(new Error(`Daemon exited during startup with code ${code}`))
|
|
}
|
|
|
|
timer = setTimeout(() => {
|
|
fail(new Error('Daemon startup timed out'))
|
|
}, 10000)
|
|
|
|
child.on('message', onReadyMessage)
|
|
child.on('error', onStartupError)
|
|
child.on('exit', onStartupExit)
|
|
})
|
|
|
|
return {
|
|
shutdown: async () => {
|
|
if (child.pid) {
|
|
try {
|
|
process.kill(child.pid, 'SIGTERM')
|
|
} catch {
|
|
// Already dead
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
|
|
logDaemonMilestone('daemon-init-start')
|
|
const runtimeDir = getRuntimeDir()
|
|
|
|
const newSpawner = new DaemonSpawner({
|
|
runtimeDir,
|
|
launcher: createOutOfProcessLauncher(runtimeDir)
|
|
})
|
|
|
|
// Why: assign spawner/adapter only after both succeed. If ensureRunning()
|
|
// throws, a stale spawner would prevent shutdownDaemon() from cleaning up
|
|
// correctly on retry.
|
|
const info = await newSpawner.ensureRunning()
|
|
logDaemonMilestone('daemon-current-ready')
|
|
if (signal?.aborted) {
|
|
// Why: startup fail-open may already have allowed fallback LocalPtyProvider
|
|
// PTYs to spawn. A late daemon swap would strand those PTYs on the old owner.
|
|
return
|
|
}
|
|
|
|
const newAdapter = new DaemonPtyAdapter({
|
|
socketPath: info.socketPath,
|
|
tokenPath: info.tokenPath,
|
|
historyPath: getHistoryDir(),
|
|
// Why: when the daemon process dies (e.g. killed by a signal, OOM, or
|
|
// cascading from a force-quit of child processes), the adapter's
|
|
// ensureConnected() detects the dead socket and calls this to fork a
|
|
// replacement daemon before retrying the connection.
|
|
respawn: async () => {
|
|
console.warn('[daemon] Daemon process died — respawning')
|
|
newSpawner.resetHandle()
|
|
await newSpawner.ensureRunning()
|
|
}
|
|
})
|
|
|
|
const legacyAdapters = await createLegacyDaemonAdapters(runtimeDir)
|
|
const routedAdapter =
|
|
legacyAdapters.length > 0
|
|
? new DaemonPtyRouter({
|
|
current: newAdapter,
|
|
legacy: legacyAdapters
|
|
})
|
|
: newAdapter
|
|
if (routedAdapter instanceof DaemonPtyRouter) {
|
|
await routedAdapter.discoverLegacySessions()
|
|
}
|
|
if (signal?.aborted) {
|
|
// Why: same late-swap guard after legacy discovery, which can also exceed
|
|
// the first-window startup timeout on slow or stale daemon state.
|
|
return
|
|
}
|
|
|
|
spawner = newSpawner
|
|
adapter = routedAdapter
|
|
setLocalPtyProvider(routedAdapter)
|
|
// Why: desktop startup now lets the first window register PTY listeners
|
|
// before daemon init finishes. Rebind here so daemon PTYs still fan out
|
|
// data/exit events through the renderer and runtime listeners.
|
|
rebindLocalProviderListeners()
|
|
logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length })
|
|
}
|
|
|
|
// Why: the Manage Sessions IPC handlers need read access to the current
|
|
// adapter/router to list sessions, kill them, etc. Exposed as a narrow getter
|
|
// rather than exporting the module-level variable to keep the "swap on
|
|
// restart" invariant in one place (replaceDaemonProvider).
|
|
export function getDaemonProvider(): DaemonPtyRouter | DaemonPtyAdapter | null {
|
|
return adapter
|
|
}
|
|
|
|
// Why: the "Restart daemon" flow rebuilds the current-protocol adapter and
|
|
// must update both the module-level `adapter` singleton here and the
|
|
// `localProvider` reference inside ipc/pty.ts. Without this helper they could
|
|
// drift — app-quit would dispose a stale adapter reference.
|
|
export function replaceDaemonProvider(newAdapter: DaemonPtyAdapter | DaemonPtyRouter): void {
|
|
adapter = newAdapter
|
|
setLocalPtyProvider(newAdapter)
|
|
}
|
|
|
|
export type RestartDaemonResult = {
|
|
killedCount: number
|
|
}
|
|
|
|
// Why: the 7-step sequence from docs/daemon-staleness-ux.md §Phase 1 restart.
|
|
// Current-protocol only — legacy adapters are preserved and route to their
|
|
// original daemons with no respawn path. See the design doc for rationale on
|
|
// each step, notably why synthetic exits must fan out *before* the listener
|
|
// unsubscribe.
|
|
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 =
|
|
currentAdapter instanceof DaemonPtyRouter ? currentAdapter.getCurrentAdapter() : currentAdapter
|
|
const legacyAdapters =
|
|
currentAdapter instanceof DaemonPtyRouter ? [...currentAdapter.getLegacyAdapters()] : []
|
|
|
|
// Step 1: synthesize pty:exit for every active session on the current
|
|
// adapter BEFORE any teardown. The daemon's kill-all-and-shutdown path
|
|
// explicitly does not fan onExit to clients (session.ts:246-252), so
|
|
// without this the renderer would never see exits and would black-hole
|
|
// writes against the disposed adapter.
|
|
const killedCount = currentOnly.getActiveSessionIds().length
|
|
currentOnly.fanoutSyntheticExits(-1)
|
|
|
|
// Step 2: detach renderer listeners from the current adapter. Must happen
|
|
// AFTER step 1 so the synthesized exits actually reach the renderer, and
|
|
// BEFORE step 6 so the new provider isn't bound with stale listeners.
|
|
unbindLocalProviderListeners()
|
|
|
|
// Step 3: kill the current-protocol daemon process (shutdown RPC → fallback
|
|
// killStaleDaemon → socket/pid unlink). Legacy adapters untouched.
|
|
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
|
|
|
|
// Step 4: reuse the existing spawner so the respawn closure baked into
|
|
// long-lived adapters stays valid. Do NOT construct a new DaemonSpawner.
|
|
currentSpawner.resetHandle()
|
|
const info = await currentSpawner.ensureRunning()
|
|
|
|
// Step 5: build a fresh current adapter against the respawned daemon. Its
|
|
// respawn callback closes over the same spawner instance (identical to the
|
|
// crash-respawn closure in initDaemonPtyProvider).
|
|
const newCurrent = new DaemonPtyAdapter({
|
|
socketPath: info.socketPath,
|
|
tokenPath: info.tokenPath,
|
|
historyPath: getHistoryDir(),
|
|
respawn: async () => {
|
|
console.warn('[daemon] Daemon process died — respawning')
|
|
currentSpawner.resetHandle()
|
|
await currentSpawner.ensureRunning()
|
|
}
|
|
})
|
|
|
|
// Re-wrap in router if there were legacy adapters at startup; otherwise
|
|
// point straight at the new adapter. Legacy instances are preserved by
|
|
// reference — they still route to the same pre-upgrade daemons.
|
|
const newProvider =
|
|
legacyAdapters.length > 0
|
|
? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters })
|
|
: newCurrent
|
|
if (newProvider instanceof DaemonPtyRouter) {
|
|
await newProvider.discoverLegacySessions()
|
|
}
|
|
|
|
// Why: drain the outgoing router's subscriptions from the shared legacy
|
|
// adapters before installing the new router (which subscribes fresh). Must
|
|
// run *after* the new provider exists so no adapter event is unhandled in
|
|
// the narrow window, and *before* replaceDaemonProvider so the swap is
|
|
// atomic from the renderer's perspective. Plain dispose() would also tear
|
|
// down the legacy adapters themselves — use the router-only variant.
|
|
if (currentAdapter instanceof DaemonPtyRouter) {
|
|
currentAdapter.disposeRouterOnly()
|
|
}
|
|
|
|
// Step 6: swap module state (adapter + localProvider) atomically.
|
|
replaceDaemonProvider(newProvider)
|
|
|
|
// Step 7: rebind renderer listeners against the new provider.
|
|
rebindLocalProviderListeners()
|
|
|
|
return { killedCount }
|
|
}
|
|
|
|
// Why: disconnect from the daemon without killing it. The daemon runs as a
|
|
// separate process and survives app quit — sessions stay alive for warm
|
|
// reattach on next launch. Leave history sessions marked "unclean" here so a
|
|
// later daemon crash while Orca is closed is still recoverable on next launch.
|
|
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
|
|
try {
|
|
unlinkSync(getDaemonPidPath(getRuntimeDir()))
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
}
|
|
|
|
export type OrphanedDaemonCleanupResult = {
|
|
/** True when we detected a live daemon socket and connected to tear it down.
|
|
* False when no daemon was running (fresh install or clean previous quit). */
|
|
cleaned: boolean
|
|
/** Number of live PTY sessions killed during cleanup. The caller surfaces this
|
|
* to the user so they know what background work was stopped. */
|
|
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) {
|
|
// Why: still best-effort remove a stale socket file so a future opt-in
|
|
// launch doesn't hit EADDRINUSE when the daemon tries to 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
|
|
|
|
// Why: the daemon exposes a single-shot `shutdown` RPC (daemon-server.ts)
|
|
// that kills every session and then terminates its own process. Using it
|
|
// avoids the race between per-session `kill` calls and the daemon exiting.
|
|
await client.request('shutdown', { killSessions: true }).catch(() => {
|
|
// Daemon exits immediately after handling the RPC — the socket may close
|
|
// before the reply round-trips. Treat that as success.
|
|
})
|
|
didRequestShutdown = true
|
|
} catch {
|
|
// Why: previous-protocol daemons may be wedged or too old to complete the
|
|
// RPC cleanup path. Fall back to PID cleanup, but daemon-health only
|
|
// unlinks a live socket after proving it killed the matching process.
|
|
didKillStaleDaemon = await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion)
|
|
} finally {
|
|
client.disconnect()
|
|
}
|
|
|
|
// Why: after `shutdown`, the daemon unlinks its socket itself — but on some
|
|
// crash paths the file lingers. Clean up defensively so a later opt-in
|
|
// relaunch can bind cleanly.
|
|
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 createLegacyDaemonAdapters(runtimeDir: string): 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: dead legacy daemons leave pid/token files behind forever (one per
|
|
// protocol bump). A stale pid eventually gets recycled by an unrelated
|
|
// process, turning any future identity check into a PowerShell spawn.
|
|
// The socket is provably dead, so remove the leftovers — mirrors what
|
|
// cleanupDaemonForProtocol already does for the current version.
|
|
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
|
|
}
|
|
// Why: old daemon PTYs can be running long-lived agents during an app
|
|
// upgrade. Keep those sessions routed to their original daemon while new
|
|
// terminals use the current protocol, instead of killing background work.
|
|
// Legacy adapters intentionally do not respawn: respawning an old protocol
|
|
// daemon from new code would recreate stale env semantics and can be less
|
|
// predictable than letting the session fail if that old daemon dies.
|
|
// Why historyPath is still passed: checkpoint writes will fail silently
|
|
// (pre-v4 daemons don't support getSnapshot), but the HistoryManager is
|
|
// still needed for cleanup — close/exit events must remove history dirs
|
|
// and mark meta.json as ended. Without it, a later v4 session reusing
|
|
// the same ID could false-restore stale scrollback.bin.
|
|
adapters.push(
|
|
new DaemonPtyAdapter({
|
|
socketPath,
|
|
tokenPath,
|
|
protocolVersion,
|
|
historyPath: getHistoryDir()
|
|
})
|
|
)
|
|
}
|
|
return adapters
|
|
}
|