mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
268 lines
9.2 KiB
TypeScript
268 lines
9.2 KiB
TypeScript
import { join } from 'path'
|
|
import { app } from 'electron'
|
|
import { mkdirSync, existsSync, unlinkSync } from 'fs'
|
|
import { fork } from 'child_process'
|
|
import { connect } from 'net'
|
|
import {
|
|
DaemonSpawner,
|
|
getDaemonSocketPath,
|
|
getDaemonTokenPath,
|
|
type DaemonLauncher
|
|
} from './daemon-spawner'
|
|
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
|
import { DaemonClient } from './client'
|
|
import type { ListSessionsResult } from './types'
|
|
import { setLocalPtyProvider } from '../ipc/pty'
|
|
|
|
let spawner: DaemonSpawner | null = null
|
|
let adapter: DaemonPtyAdapter | 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
|
|
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 })
|
|
const timer = setTimeout(() => {
|
|
sock.destroy()
|
|
resolve(false)
|
|
}, 1000)
|
|
sock.on('connect', () => {
|
|
clearTimeout(timer)
|
|
sock.destroy()
|
|
resolve(true)
|
|
})
|
|
sock.on('error', () => {
|
|
clearTimeout(timer)
|
|
resolve(false)
|
|
})
|
|
})
|
|
}
|
|
|
|
function createOutOfProcessLauncher(): DaemonLauncher {
|
|
return async (socketPath, tokenPath) => {
|
|
const alive = await probeSocket(socketPath)
|
|
if (alive) {
|
|
// Why: daemon is already running from a previous app session.
|
|
// No new process to manage — return a no-op shutdown handle.
|
|
return { shutdown: async () => {} }
|
|
}
|
|
|
|
// Why: stale socket file from a crashed daemon blocks the new server
|
|
// from binding. Remove it before spawning.
|
|
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
|
unlinkSync(socketPath)
|
|
}
|
|
|
|
const entryPath = getDaemonEntryPath()
|
|
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
|
|
// 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' }
|
|
})
|
|
|
|
// Wait for the daemon to signal readiness via IPC
|
|
await new Promise<void>((resolve, reject) => {
|
|
const fail = (error: Error): void => {
|
|
clearTimeout(timer)
|
|
if (child.pid) {
|
|
try {
|
|
process.kill(child.pid, 'SIGTERM')
|
|
} catch {
|
|
// Already dead
|
|
}
|
|
}
|
|
reject(error)
|
|
}
|
|
const timer = setTimeout(() => {
|
|
fail(new Error('Daemon startup timed out'))
|
|
}, 10000)
|
|
|
|
child.on('message', (msg: unknown) => {
|
|
if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') {
|
|
clearTimeout(timer)
|
|
// Why: disconnect IPC channel and unref so Electron can exit
|
|
// without waiting for the daemon. The daemon keeps running.
|
|
child.disconnect()
|
|
child.unref()
|
|
resolve()
|
|
}
|
|
})
|
|
|
|
child.on('error', (err) => {
|
|
fail(err)
|
|
})
|
|
|
|
child.on('exit', (code) => {
|
|
fail(new Error(`Daemon exited during startup with code ${code}`))
|
|
})
|
|
})
|
|
|
|
return {
|
|
shutdown: async () => {
|
|
if (child.pid) {
|
|
try {
|
|
process.kill(child.pid, 'SIGTERM')
|
|
} catch {
|
|
// Already dead
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function initDaemonPtyProvider(): Promise<void> {
|
|
const runtimeDir = getRuntimeDir()
|
|
|
|
const newSpawner = new DaemonSpawner({
|
|
runtimeDir,
|
|
launcher: createOutOfProcessLauncher()
|
|
})
|
|
|
|
// 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()
|
|
|
|
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()
|
|
}
|
|
})
|
|
|
|
spawner = newSpawner
|
|
adapter = newAdapter
|
|
setLocalPtyProvider(adapter)
|
|
}
|
|
|
|
// 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 function disconnectDaemon(): void {
|
|
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 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
|
|
}
|
|
|
|
/** Detect and tear down an orphaned daemon left behind by a previous app
|
|
* session (e.g. a user who had `experimentalTerminalDaemon` enabled on an
|
|
* older build and is now launching a build where the feature is disabled).
|
|
*
|
|
* Why it matters: the daemon is designed to outlive the Electron process.
|
|
* If we just skip `initDaemonPtyProvider()` on this launch, any live sessions
|
|
* from the previous session keep running invisibly — consuming CPU / holding
|
|
* files open / re-launching on every boot because nothing ever kills them.
|
|
* This helper connects to the existing socket, enumerates sessions, and asks
|
|
* the daemon to shut itself down (which terminates all PTYs). */
|
|
export async function cleanupOrphanedDaemon(): Promise<OrphanedDaemonCleanupResult> {
|
|
const runtimeDir = getRuntimeDir()
|
|
const socketPath = getDaemonSocketPath(runtimeDir)
|
|
const tokenPath = getDaemonTokenPath(runtimeDir)
|
|
|
|
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
|
|
}
|
|
}
|
|
return { cleaned: false, killedCount: 0 }
|
|
}
|
|
|
|
const client = new DaemonClient({ socketPath, tokenPath })
|
|
let killedCount = 0
|
|
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:263)
|
|
// 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.
|
|
})
|
|
} 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 (process.platform !== 'win32' && existsSync(socketPath)) {
|
|
try {
|
|
unlinkSync(socketPath)
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
}
|
|
|
|
return { cleaned: true, killedCount }
|
|
}
|