From 504bdd173b5530cee7fe4e6e8d60b1ab7aa7b35e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 4 Jul 2026 03:25:46 -0400 Subject: [PATCH] WIP (mac-side drafts, untested, known lint/syntax issues): daemon health-check retry via socket probe, legacy-adoption retry + null-tolerant token cleanup, runtime staging, periodic sleeping-agent capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handover scaffolding for the Windows-local agent — fix or discard freely. Co-authored-by: Orca --- src/main/daemon/daemon-init.test.ts | 11 +- src/main/daemon/daemon-init.ts | 413 ++++++++++-------- .../daemon/daemon-legacy-adoption.test.ts | 168 +++++++ src/main/daemon/daemon-legacy-adoption.ts | 171 ++++++++ .../daemon/daemon-runtime-staging.test.ts | 224 ++++++++++ src/main/daemon/daemon-runtime-staging.ts | 333 ++++++++++++++ src/main/daemon/daemon-socket-probe.ts | 51 +++ src/renderer/src/App.tsx | 10 + .../lib/sleeping-agent-periodic-capture.ts | 22 + src/renderer/src/store/slices/agent-status.ts | 39 +- 10 files changed, 1244 insertions(+), 198 deletions(-) create mode 100644 src/main/daemon/daemon-legacy-adoption.test.ts create mode 100644 src/main/daemon/daemon-legacy-adoption.ts create mode 100644 src/main/daemon/daemon-runtime-staging.test.ts create mode 100644 src/main/daemon/daemon-runtime-staging.ts create mode 100644 src/main/daemon/daemon-socket-probe.ts create mode 100644 src/renderer/src/lib/sleeping-agent-periodic-capture.ts diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 349cd1fbec7..239feaf7097 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -210,7 +210,13 @@ vi.mock('fs', () => ({ mkdirSync: vi.fn(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), unlinkSync: vi.fn(), - writeFileSync: writeFileSyncMock + writeFileSync: writeFileSyncMock, + // Why: legacy adoption reads pid files and runtime pruning lists the + // daemon dir; both are no-ops in these tests (no evidence files exist). + readFileSync: vi.fn(() => { + throw new Error('ENOENT') + }), + readdirSync: vi.fn(() => []) })) vi.mock('child_process', () => ({ fork: forkMock })) @@ -224,7 +230,8 @@ vi.mock('./daemon-health', () => ({ healthCheckDaemon: healthCheckDaemonMock, isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock, killStaleDaemon: killStaleDaemonMock, - getProcessStartedAtMs: getProcessStartedAtMsMock + getProcessStartedAtMs: getProcessStartedAtMsMock, + parseDaemonPidFile: vi.fn(() => null) })) vi.mock('./client', () => ({ DaemonClient: daemonClientMock })) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index ff18ecc3397..887e6884179 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -8,9 +8,8 @@ module-level spawner/adapter singletons must stay co-located so a future change cannot leave them drifting out of sync. */ import { join } from 'node:path' import { app } from 'electron' -import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { fork } from 'node:child_process' -import { connect } from 'node:net' import { DaemonSpawner, getDaemonPidPath, @@ -23,18 +22,23 @@ import { import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' +import { PROTOCOL_VERSION, type ListSessionsResult } from './types' +import { probeSocket } from './daemon-socket-probe' +import { adoptLegacyDaemons } from './daemon-legacy-adoption' import { - PREVIOUS_DAEMON_PROTOCOL_VERSIONS, - PROTOCOL_VERSION, - type ListSessionsResult -} from './types' + getDaemonRuntimeVersionDir, + pruneDaemonRuntimeStaging, + stageDaemonRuntime, + type StagedDaemonRuntime +} from './daemon-runtime-staging' import { getMacDaemonSystemResolverHealth, getDaemonLaunchIdentity, getProcessStartedAtMs, checkDaemonHealth, isDaemonStaleForCurrentBundle, - killStaleDaemon + killStaleDaemon, + parseDaemonPidFile } from './daemon-health' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { @@ -89,48 +93,57 @@ function getDaemonEntryPath(): string { 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 { - return new Promise((resolve) => { - if (process.platform !== 'win32' && !existsSync(socketPath)) { - resolve(false) - return - } - const sock = connect({ path: socketPath }) - let settled = false - let timer: ReturnType - 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 getDaemonRuntimeStagingRoot(): string { + // Why: the Windows staging includes an Electron executable copy; keep it + // out of userData (%APPDATA% Roaming — synced on roaming profiles) and out + // of the NSIS $INSTDIR sweep (%LOCALAPPDATA%\Programs\orca). + if (process.platform === 'win32' && process.env.LOCALAPPDATA) { + return join(process.env.LOCALAPPDATA, 'orca', 'daemon-runtime') + } + return join(getRuntimeDir(), 'runtime') +} - 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) +// Why: packaged installs are replaced in-place by updaters (NSIS overwrites +// %LOCALAPPDATA%\Programs\orca on Windows), so a detached daemon must not +// execute files from the install dir. Dev builds fork straight from out/ — +// entry-path identity checks already handle worktree staleness there. +async function resolveDaemonRuntime(installEntryPath: string): Promise { + if (!app.isPackaged) { + return { entryPath: installEntryPath, execPath: null, staged: false } + } + return stageDaemonRuntime({ + installEntryPath, + stagingRoot: getDaemonRuntimeStagingRoot(), + appVersion: app.getVersion(), + platform: process.platform, + execPath: process.execPath }) } +function collectPidFileEntryPaths(runtimeDir: string): string[] { + const entryPaths: string[] = [] + let names: string[] + try { + names = readdirSync(runtimeDir) + } catch { + return entryPaths + } + for (const name of names) { + if (!/^daemon-v\d+\.pid$/.test(name)) { + continue + } + try { + const parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, name), 'utf8')) + if (parsed?.entryPath) { + entryPaths.push(parsed.entryPath) + } + } catch { + // Unreadable pid file — nothing to preserve for it. + } + } + return entryPaths +} + async function getAliveDaemonSessionCount( socketPath: string, tokenPath: string, @@ -183,7 +196,9 @@ async function shouldPreserveDaemonWithLiveSessions( function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { return async (socketPath, tokenPath) => { - const entryPath = getDaemonEntryPath() + const installEntryPath = getDaemonEntryPath() + const runtime = await resolveDaemonRuntime(installEntryPath) + const entryPath = runtime.entryPath const health = await checkDaemonHealth(socketPath, tokenPath) if (health === 'healthy') { const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) @@ -259,114 +274,158 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // 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 + // Why a candidate ladder: the staged Windows executable copy is validated + // by the ready handshake itself. If a staged runtime cannot boot (missing + // Electron support file, AV quarantine), fall back to the default + // executable and finally to the install-dir entry — worst case is + // exactly today's behavior instead of no daemon at all. + const candidates: { entryPath: string; execPath?: string }[] = [] + if (runtime.staged) { + if (runtime.execPath) { + candidates.push({ entryPath: runtime.entryPath, execPath: runtime.execPath }) } - }) + candidates.push({ entryPath: runtime.entryPath }) + } + candidates.push({ entryPath: installEntryPath }) - // Wait for the daemon to signal readiness via IPC - await new Promise((resolve, reject) => { - let timer: ReturnType | undefined - let settled = false - function cleanupStartupListeners(): void { - if (timer) { - clearTimeout(timer) + let lastError: Error | null = null + for (const candidate of candidates) { + try { + return await forkDaemonUntilReady(candidate, socketPath, tokenPath, runtimeDir) + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + if (candidate !== candidates[candidates.length - 1]) { + console.warn( + `[daemon] Daemon launch failed via ${candidate.execPath ?? 'default exec'} + ${candidate.entryPath} — trying next fallback: ${lastError.message}` + ) } - child.off('message', onReadyMessage) - child.off('error', onStartupError) - child.off('exit', onStartupExit) } - function fail(error: Error): void { + } + throw lastError ?? new Error('Daemon launch failed with no candidates') + } +} + +async function forkDaemonUntilReady( + candidate: { entryPath: string; execPath?: string }, + socketPath: string, + tokenPath: string, + runtimeDir: string +): Promise { + const userDataPath = app.getPath('userData') + const child = fork(candidate.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 execPath: on Windows the staged executable copy lives outside the + // install dir under a non-Orca.exe image name, which is what lets the + // daemon escape the NSIS updater's kill sweep (it stops processes by + // install-dir path, or by Orca.exe image name when PowerShell is absent). + ...(candidate.execPath ? { execPath: candidate.execPath } : {}), + ...(process.platform === 'win32' ? { windowsHide: true } : {}), + // 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((resolve, reject) => { + let timer: ReturnType | 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) { - 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() + // 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. + // entryPath records the path actually forked (staged or install) + // so runtime-staging pruning never deletes a live daemon's files. + writeFileSync( + getDaemonPidPath(runtimeDir), + serializeDaemonPidFile({ + pid: child.pid, + startedAtMs: getProcessStartedAtMs(child.pid), + entryPath: candidate.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 onStartupError(err: Error): void { + fail(err) + } - function onStartupExit(code: number | null): void { - fail(new Error(`Daemon exited during startup with code ${code}`)) - } + 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) + timer = setTimeout(() => { + fail(new Error('Daemon startup timed out')) + }, 10000) - child.on('message', onReadyMessage) - child.on('error', onStartupError) - child.on('exit', onStartupExit) - }) + 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 - } + return { + shutdown: async () => { + if (child.pid) { + try { + process.kill(child.pid, 'SIGTERM') + } catch { + // Already dead } } } @@ -417,7 +476,16 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise } }) - const legacyAdapters = await createLegacyDaemonAdapters(runtimeDir) + const adoption = await adoptLegacyDaemons(runtimeDir, getHistoryDir()) + const legacyAdapters = adoption.adapters + if (adoption.unreachableVersions.length > 0 && legacyAdapters.length === 0) { + // Why so loud: this is the exact signature of the update-crossing bug — + // sessions exist behind a previous-protocol daemon but none were adopted, + // so every terminal will silently cold-restore instead of reattaching. + console.error( + `[daemon] Previous-version daemon(s) v${adoption.unreachableVersions.join(', v')} appear live but zero legacy adapters were created — their sessions will NOT reattach this launch` + ) + } const routedAdapter = launchMode === 'degraded-new-pty-fallback' ? new DegradedDaemonPtyProvider({ @@ -452,7 +520,22 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise // 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 }) + logDaemonMilestone('daemon-init-done', { + legacyAdapters: legacyAdapters.length, + unreachableLegacyVersions: adoption.unreachableVersions + }) + + if (app.isPackaged) { + // Why fire-and-forget: pruning stale staged runtimes is pure disk + // hygiene; it must never delay startup or fail init. Pid-file entry + // paths guard runtimes that may still back a live (legacy) daemon. + const stagingRoot = getDaemonRuntimeStagingRoot() + void pruneDaemonRuntimeStaging({ + stagingRoot, + keepVersionDir: getDaemonRuntimeVersionDir(stagingRoot, app.getVersion()), + referencedEntryPaths: collectPidFileEntryPaths(runtimeDir) + }).catch(() => {}) + } } // Why: the Manage Sessions IPC handlers need read access to the current @@ -708,55 +791,3 @@ export async function cleanupDaemonForProtocol( return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount } } -async function createLegacyDaemonAdapters(runtimeDir: string): Promise { - 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 -} diff --git a/src/main/daemon/daemon-legacy-adoption.test.ts b/src/main/daemon/daemon-legacy-adoption.test.ts new file mode 100644 index 00000000000..d49e317327a --- /dev/null +++ b/src/main/daemon/daemon-legacy-adoption.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createServer, type Server } from 'node:net' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { adoptLegacyDaemons } from './daemon-legacy-adoption' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { + getDaemonPidPath, + getDaemonSocketPath, + getDaemonTokenPath, + serializeDaemonPidFile +} from './daemon-spawner' + +const LEGACY_VERSION = 17 + +type CreatedAdapter = { + socketPath: string + tokenPath: string + protocolVersion: number + historyPath: string +} + +function stubAdapterFactory(created: CreatedAdapter[]) { + return (opts: CreatedAdapter): DaemonPtyAdapter => { + created.push(opts) + return { protocolVersion: opts.protocolVersion } as unknown as DaemonPtyAdapter + } +} + +describe('adoptLegacyDaemons', () => { + let dir: string + let historyDir: string + let warnSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-legacy-adoption-test-')) + historyDir = join(dir, 'history') + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + errorSpy.mockRestore() + rmSync(dir, { recursive: true, force: true }) + }) + + it('adopts a live previous-version daemon reachable on its socket', async () => { + // Simulate a still-running pre-upgrade daemon: a real server listening on + // the exact socket path the previous protocol version would use. + const socketPath = getDaemonSocketPath(dir, LEGACY_VERSION) + const server = await new Promise((resolve, reject) => { + const created = createServer() + created.once('error', reject) + created.listen(socketPath, () => resolve(created)) + }) + writeFileSync(getDaemonTokenPath(dir, LEGACY_VERSION), 'token', 'utf8') + + try { + const created: CreatedAdapter[] = [] + const result = await adoptLegacyDaemons(dir, historyDir, { + protocolVersions: [LEGACY_VERSION], + createAdapter: stubAdapterFactory(created) + }) + + expect(result.adapters).toHaveLength(1) + expect(result.unreachableVersions).toEqual([]) + expect(created[0]).toMatchObject({ + socketPath, + protocolVersion: LEGACY_VERSION, + historyPath: historyDir + }) + expect(existsSync(getDaemonTokenPath(dir, LEGACY_VERSION))).toBe(true) + } finally { + await new Promise((resolve) => server.close(resolve)) + } + }) + + it('retries transient probe failures before adopting', async () => { + writeFileSync(getDaemonTokenPath(dir, LEGACY_VERSION), 'token', 'utf8') + let calls = 0 + const probe = vi.fn(async () => { + calls += 1 + return calls >= 3 + }) + + const created: CreatedAdapter[] = [] + const result = await adoptLegacyDaemons(dir, historyDir, { + protocolVersions: [LEGACY_VERSION], + probe, + delay: async () => {}, + createAdapter: stubAdapterFactory(created) + }) + + expect(probe).toHaveBeenCalledTimes(3) + expect(result.adapters).toHaveLength(1) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('attempt 3/3')) + }) + + it('keeps token/pid files and logs loudly when the daemon pid is alive but unreachable', async () => { + const tokenPath = getDaemonTokenPath(dir, LEGACY_VERSION) + const pidPath = getDaemonPidPath(dir, LEGACY_VERSION) + writeFileSync(tokenPath, 'token', 'utf8') + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: process.pid, startedAtMs: null }), + 'utf8' + ) + const probe = vi.fn(async () => false) + + const result = await adoptLegacyDaemons(dir, historyDir, { + protocolVersions: [LEGACY_VERSION], + probe, + delay: async () => {}, + isPidAlive: () => true, + createAdapter: stubAdapterFactory([]) + }) + + expect(probe).toHaveBeenCalledTimes(3) + expect(result.adapters).toHaveLength(0) + expect(result.unreachableVersions).toEqual([LEGACY_VERSION]) + // The token file is the only credential that can ever adopt these + // sessions — a transient failure must not delete it. + expect(existsSync(tokenPath)).toBe(true) + expect(existsSync(pidPath)).toBe(true) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining(`Previous-version daemon v${LEGACY_VERSION} looks alive`) + ) + }) + + it('cleans up token/pid files when the daemon pid is provably dead', async () => { + const tokenPath = getDaemonTokenPath(dir, LEGACY_VERSION) + const pidPath = getDaemonPidPath(dir, LEGACY_VERSION) + writeFileSync(tokenPath, 'token', 'utf8') + writeFileSync(pidPath, serializeDaemonPidFile({ pid: 999999, startedAtMs: null }), 'utf8') + + const result = await adoptLegacyDaemons(dir, historyDir, { + protocolVersions: [LEGACY_VERSION], + probe: async () => false, + delay: async () => {}, + isPidAlive: () => false, + createAdapter: stubAdapterFactory([]) + }) + + expect(result.adapters).toHaveLength(0) + expect(result.unreachableVersions).toEqual([]) + expect(existsSync(tokenPath)).toBe(false) + expect(existsSync(pidPath)).toBe(false) + }) + + it('probes once and stays quiet when no evidence files exist', async () => { + const probe = vi.fn(async () => false) + + const result = await adoptLegacyDaemons(dir, historyDir, { + protocolVersions: [LEGACY_VERSION], + probe, + delay: async () => {}, + createAdapter: stubAdapterFactory([]) + }) + + expect(probe).toHaveBeenCalledTimes(1) + expect(result.adapters).toHaveLength(0) + expect(result.unreachableVersions).toEqual([]) + expect(errorSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/daemon-legacy-adoption.ts b/src/main/daemon/daemon-legacy-adoption.ts new file mode 100644 index 00000000000..43c9cbdf4a8 --- /dev/null +++ b/src/main/daemon/daemon-legacy-adoption.ts @@ -0,0 +1,171 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' +import { parseDaemonPidFile } from './daemon-health' +import { probeSocket } from './daemon-socket-probe' +import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS } from './types' + +// Why: right after an app update the machine is busy (installer teardown, AV +// scans) and Windows named-pipe connects can transiently fail or stall. A +// single 1s probe misclassifying a live previous-version daemon as dead used +// to permanently strand its sessions (the old code deleted the token file the +// adoption path needs). Retry before concluding anything, and only clean up +// files when the pid provably no longer runs. +export const LEGACY_ADOPTION_PROBE_TIMEOUT_MS = 2000 +export const LEGACY_ADOPTION_PROBE_ATTEMPTS = 3 +export const LEGACY_ADOPTION_RETRY_DELAY_MS = [250, 750] + +export type LegacyDaemonAdoptionResult = { + adapters: DaemonPtyAdapter[] + /** Protocol versions whose on-disk files said a daemon may still be running + * but whose socket/pipe never accepted a connection. Sessions owned by + * those daemons cannot be adopted this launch. */ + unreachableVersions: number[] +} + +type LegacyAdapterFactory = (opts: { + socketPath: string + tokenPath: string + protocolVersion: number + historyPath: string +}) => DaemonPtyAdapter + +export type LegacyDaemonAdoptionOverrides = { + probe?: (socketPath: string, timeoutMs?: number) => Promise + delay?: (ms: number) => Promise + createAdapter?: LegacyAdapterFactory + protocolVersions?: readonly number[] + isPidAlive?: (pid: number) => boolean +} + +function defaultDelay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function defaultIsPidAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +function readLegacyDaemonPid(pidPath: string): number | null { + try { + return parseDaemonPidFile(readFileSync(pidPath, 'utf8'))?.pid ?? null + } catch { + return null + } +} + +function removeDeadLegacyDaemonFiles( + socketPath: string, + tokenPath: string, + pidPath: string +): void { + // 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. + for (const stalePath of [pidPath, tokenPath]) { + try { + unlinkSync(stalePath) + } catch { + // Best-effort + } + } + if (process.platform !== 'win32' && existsSync(socketPath)) { + try { + unlinkSync(socketPath) + } catch { + // Best-effort + } + } +} + +// 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. +export async function adoptLegacyDaemons( + runtimeDir: string, + historyPath: string, + overrides: LegacyDaemonAdoptionOverrides = {} +): Promise { + const probe = overrides.probe ?? probeSocket + const delay = overrides.delay ?? defaultDelay + const isPidAlive = overrides.isPidAlive ?? defaultIsPidAlive + const createAdapter: LegacyAdapterFactory = + overrides.createAdapter ?? ((opts) => new DaemonPtyAdapter(opts)) + const protocolVersions = overrides.protocolVersions ?? PREVIOUS_DAEMON_PROTOCOL_VERSIONS + + const adapters: DaemonPtyAdapter[] = [] + const unreachableVersions: number[] = [] + for (const protocolVersion of protocolVersions) { + const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion) + const tokenPath = getDaemonTokenPath(runtimeDir, protocolVersion) + const pidPath = getDaemonPidPath(runtimeDir, protocolVersion) + + // Why token presence is the retry signal: the token file is written when + // a daemon for that protocol actually ran, and adoption cannot + // authenticate without it — so it is both the "a daemon may still be + // running" evidence and the precondition for adoption being useful. + // Without it a single probe keeps startup fast across the legacy list. + const tokenExists = existsSync(tokenPath) + const attempts = tokenExists ? LEGACY_ADOPTION_PROBE_ATTEMPTS : 1 + + let alive = false + for (let attempt = 1; attempt <= attempts; attempt++) { + alive = await probe(socketPath, tokenExists ? LEGACY_ADOPTION_PROBE_TIMEOUT_MS : undefined) + if (alive) { + if (attempt > 1) { + console.warn( + `[daemon] Previous-version daemon v${protocolVersion} accepted a connection on attempt ${attempt}/${attempts}` + ) + } + break + } + if (attempt < attempts) { + await delay( + LEGACY_ADOPTION_RETRY_DELAY_MS[ + Math.min(attempt - 1, LEGACY_ADOPTION_RETRY_DELAY_MS.length - 1) + ] + ) + } + } + + if (!alive) { + const pid = readLegacyDaemonPid(pidPath) + if (pid !== null && isPidAlive(pid)) { + // Why: a live pid with an unreachable pipe is exactly the transient + // failure mode this launch cannot distinguish from a wedged daemon. + // Deleting the token here would make adoption impossible forever, so + // keep the files and let the next launch retry. + if (tokenExists) { + unreachableVersions.push(protocolVersion) + console.error( + `[daemon] Previous-version daemon v${protocolVersion} looks alive (pid ${pid}) but its socket/pipe refused ${attempts} connection attempts — its terminal sessions cannot be adopted this launch. Keeping its runtime files for the next launch.` + ) + } + continue + } + if (tokenExists || pid !== null) { + console.warn( + `[daemon] Previous-version daemon v${protocolVersion} is gone (stale pid/token files) — cleaning up` + ) + removeDeadLegacyDaemonFiles(socketPath, tokenPath, pidPath) + } + continue + } + + // 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(createAdapter({ socketPath, tokenPath, protocolVersion, historyPath })) + } + return { adapters, unreachableVersions } +} diff --git a/src/main/daemon/daemon-runtime-staging.test.ts b/src/main/daemon/daemon-runtime-staging.test.ts new file mode 100644 index 00000000000..71ba109fd66 --- /dev/null +++ b/src/main/daemon/daemon-runtime-staging.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + STAGED_DAEMON_EXECUTABLE_FILENAME, + getDaemonRuntimeVersionDir, + pruneDaemonRuntimeStaging, + stageDaemonRuntime +} from './daemon-runtime-staging' + +describe('daemon runtime staging', () => { + let dir: string + let installDir: string + let stagingRoot: string + let installEntryPath: string + + function writeInstallBundle(): void { + // Mirrors the real packaged layout: entry + hashed chunk + externalized + // native package under a sibling node_modules. + mkdirSync(join(installDir, 'out', 'main', 'chunks'), { recursive: true }) + installEntryPath = join(installDir, 'out', 'main', 'daemon-entry.js') + writeFileSync( + installEntryPath, + 'require("node:net");\nconst s = require("./chunks/session-Abc123.js");\n', + 'utf8' + ) + writeFileSync( + join(installDir, 'out', 'main', 'chunks', 'session-Abc123.js'), + 'require("node:fs");\nrequire("fake-pty");\n', + 'utf8' + ) + const fakePtyDir = join(installDir, 'node_modules', 'fake-pty') + mkdirSync(fakePtyDir, { recursive: true }) + writeFileSync( + join(fakePtyDir, 'package.json'), + JSON.stringify({ name: 'fake-pty', dependencies: { 'fake-addon': '1.0.0' } }), + 'utf8' + ) + writeFileSync(join(fakePtyDir, 'index.js'), 'module.exports = 1\n', 'utf8') + const fakeAddonDir = join(installDir, 'node_modules', 'fake-addon') + mkdirSync(fakeAddonDir, { recursive: true }) + writeFileSync(join(fakeAddonDir, 'package.json'), JSON.stringify({ name: 'fake-addon' })) + writeFileSync(join(fakeAddonDir, 'index.js'), 'module.exports = 2\n', 'utf8') + } + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-runtime-staging-test-')) + installDir = join(dir, 'install') + stagingRoot = join(dir, 'staging') + writeInstallBundle() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('stages the entry, its chunk closure, and external packages transitively', async () => { + const staged = await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: '1.0.0', + platform: 'darwin', + log: () => {} + }) + + expect(staged.staged).toBe(true) + expect(staged.execPath).toBeNull() + const versionDir = getDaemonRuntimeVersionDir(stagingRoot, '1.0.0') + expect(staged.entryPath).toBe(join(versionDir, 'daemon-entry.js')) + expect(existsSync(join(versionDir, 'chunks', 'session-Abc123.js'))).toBe(true) + expect(existsSync(join(versionDir, 'node_modules', 'fake-pty', 'index.js'))).toBe(true) + // Transitive dependency of the external package is staged too. + expect(existsSync(join(versionDir, 'node_modules', 'fake-addon', 'index.js'))).toBe(true) + }) + + it('reuses a completed staging for the same app version without re-copying', async () => { + const first = await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: '1.0.0', + platform: 'darwin', + log: () => {} + }) + // Mutate the source after staging; a re-run must not propagate it. + writeFileSync(installEntryPath, '// mutated\n', 'utf8') + + const second = await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: '1.0.0', + platform: 'darwin', + log: () => {} + }) + + expect(second.entryPath).toBe(first.entryPath) + expect(readFileSync(second.entryPath, 'utf8')).toContain('session-Abc123') + }) + + it('stages a fresh runtime when the app version changes', async () => { + const first = await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: '1.0.0', + platform: 'darwin', + log: () => {} + }) + const second = await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: '1.0.1', + platform: 'darwin', + log: () => {} + }) + + expect(second.entryPath).not.toBe(first.entryPath) + expect(existsSync(first.entryPath)).toBe(true) + expect(existsSync(second.entryPath)).toBe(true) + }) + + it('falls back to the install entry when staging fails', async () => { + rmSync(join(installDir, 'out', 'main', 'chunks'), { recursive: true, force: true }) + const logs: string[] = [] + + const staged = await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: '1.0.0', + platform: 'darwin', + log: (message) => logs.push(message) + }) + + expect(staged.staged).toBe(false) + expect(staged.execPath).toBeNull() + expect(staged.entryPath).toBe(installEntryPath) + expect(logs.some((message) => message.includes('falling back'))).toBe(true) + }) + + it('stages a renamed executable copy with its boot support files on win32', async () => { + const execDir = join(dir, 'winInstall') + mkdirSync(execDir, { recursive: true }) + const execPath = join(execDir, 'Orca.exe') + for (const name of [ + 'Orca.exe', + 'ffmpeg.dll', + 'icudtl.dat', + 'snapshot_blob.bin', + 'v8_context_snapshot.bin', + 'libEGL.dll', + 'Uninstall Orca.exe' + ]) { + writeFileSync(join(execDir, name), name, 'utf8') + } + + const staged = await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: '1.0.0', + platform: 'win32', + execPath, + log: () => {} + }) + + expect(staged.staged).toBe(true) + const versionDir = getDaemonRuntimeVersionDir(stagingRoot, '1.0.0') + expect(staged.execPath).toBe(join(versionDir, STAGED_DAEMON_EXECUTABLE_FILENAME)) + expect(readFileSync(staged.execPath as string, 'utf8')).toBe('Orca.exe') + expect(existsSync(join(versionDir, 'ffmpeg.dll'))).toBe(true) + expect(existsSync(join(versionDir, 'icudtl.dat'))).toBe(true) + expect(existsSync(join(versionDir, 'snapshot_blob.bin'))).toBe(true) + expect(existsSync(join(versionDir, 'v8_context_snapshot.bin'))).toBe(true) + // GPU-only DLLs and the uninstaller are not part of the node-mode boot set. + expect(existsSync(join(versionDir, 'libEGL.dll'))).toBe(false) + expect(existsSync(join(versionDir, 'Uninstall Orca.exe'))).toBe(false) + expect(existsSync(join(versionDir, 'Orca.exe'))).toBe(false) + }) + + it('tolerates missing optional executable support files', async () => { + const execDir = join(dir, 'winInstall') + mkdirSync(execDir, { recursive: true }) + const execPath = join(execDir, 'Orca.exe') + writeFileSync(execPath, 'Orca.exe', 'utf8') + + const staged = await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: '1.0.0', + platform: 'win32', + execPath, + log: () => {} + }) + + expect(staged.staged).toBe(true) + expect(staged.execPath).toBe( + join(getDaemonRuntimeVersionDir(stagingRoot, '1.0.0'), STAGED_DAEMON_EXECUTABLE_FILENAME) + ) + }) + + it('prunes stale version dirs but keeps the current and pid-referenced ones', async () => { + for (const version of ['0.9.0', '1.0.0', '1.0.1']) { + await stageDaemonRuntime({ + installEntryPath, + stagingRoot, + appVersion: version, + platform: 'darwin', + log: () => {} + }) + } + const keepDir = getDaemonRuntimeVersionDir(stagingRoot, '1.0.1') + const referencedDir = getDaemonRuntimeVersionDir(stagingRoot, '1.0.0') + const staleDir = getDaemonRuntimeVersionDir(stagingRoot, '0.9.0') + + await pruneDaemonRuntimeStaging({ + stagingRoot, + keepVersionDir: keepDir, + // A pid file for a still-running (legacy) daemon points into 1.0.0. + referencedEntryPaths: [join(referencedDir, 'daemon-entry.js')] + }) + + expect(existsSync(keepDir)).toBe(true) + expect(existsSync(referencedDir)).toBe(true) + expect(existsSync(staleDir)).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-runtime-staging.ts b/src/main/daemon/daemon-runtime-staging.ts new file mode 100644 index 00000000000..2ba46011ea1 --- /dev/null +++ b/src/main/daemon/daemon-runtime-staging.ts @@ -0,0 +1,333 @@ +/* Why this module exists: the Windows NSIS updater (electron-builder +oneClick) stops every current-user process whose executable path is inside +the install directory (PowerShell CIM sweep), falls back to +`taskkill /IM Orca.exe`, and then overwrites the install dir — including +app.asar.unpacked/out/main/daemon-entry.js and node_modules/node-pty that the +detached terminal daemon is running from. Staging the daemon runtime under +userData removes every file the daemon needs (and every lock it holds) from +the install dir, and on Windows a renamed executable copy gives the daemon a +process identity the installer's kill sweep does not match. */ +import { builtinModules } from 'node:module' +import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' + +/** Image name for the staged Windows executable. Distinct from Orca.exe so + * the NSIS `taskkill /IM Orca.exe` fallback cannot match the daemon. */ +export const STAGED_DAEMON_EXECUTABLE_FILENAME = 'orca-daemon.exe' + +const STAGING_MANIFEST_FILENAME = 'runtime-manifest.json' + +export type DaemonRuntimeStagingInput = { + /** The daemon entry file inside the install dir (or dev out/ dir). */ + installEntryPath: string + /** Version-stable parent for staged runtimes, e.g. /daemon/runtime. */ + stagingRoot: string + appVersion: string + platform?: NodeJS.Platform + /** The executable that fork() would run (process.execPath). */ + execPath?: string + log?: (message: string) => void +} + +export type StagedDaemonRuntime = { + /** Entry to fork. Falls back to installEntryPath when staging failed. */ + entryPath: string + /** Staged executable to fork with, or null to use process.execPath. */ + execPath: string | null + staged: boolean +} + +type StagingManifest = { + appVersion: string + sourceEntryPath: string + entryFile: string + execFile: string | null +} + +const NODE_BUILTINS = new Set(builtinModules) + +function requireSpecifiers(source: string): string[] { + // Why: the daemon bundle is CommonJS emitted by Rollup, so its complete + // import surface is statically visible as require("...") calls. Walking it + // at runtime keeps the copy correct across content-hashed chunk renames. + return [...source.matchAll(/require\("([^"\\]+)"\)/g)].map((match) => match[1]) +} + +function packageNameFromSpecifier(specifier: string): string { + const segments = specifier.split('/') + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] +} + +function findPackageDir(startDir: string, packageName: string): string | null { + let dir = startDir + for (let depth = 0; depth < 10; depth++) { + const candidate = join(dir, 'node_modules', ...packageName.split('/')) + if (existsSync(candidate)) { + return candidate + } + const parent = dirname(dir) + if (parent === dir) { + break + } + dir = parent + } + return null +} + +async function collectRelativeClosure(entryPath: string): Promise<{ + relativeFiles: string[] + externalPackages: string[] +}> { + const entryDir = dirname(entryPath) + const visited = new Set() + const externals = new Set() + const queue = [entryPath] + while (queue.length > 0) { + const file = queue.pop() as string + if (visited.has(file)) { + continue + } + visited.add(file) + const source = await readFile(file, 'utf8') + for (const specifier of requireSpecifiers(source)) { + if (specifier.startsWith('.')) { + queue.push(resolve(dirname(file), specifier)) + continue + } + const bare = specifier.startsWith('node:') ? specifier.slice(5) : specifier + if (!NODE_BUILTINS.has(bare)) { + externals.add(packageNameFromSpecifier(specifier)) + } + } + } + return { + relativeFiles: [...visited].map((file) => relativeWithin(entryDir, file)), + externalPackages: [...externals].sort() + } +} + +function relativeWithin(rootDir: string, file: string): string { + const normalizedRoot = resolve(rootDir) + const normalizedFile = resolve(file) + if (!normalizedFile.startsWith(normalizedRoot)) { + throw new Error(`Daemon bundle requires a file outside its directory: ${file}`) + } + return normalizedFile.slice(normalizedRoot.length + 1) +} + +async function copyExternalPackages( + packageNames: string[], + sourceStartDir: string, + targetNodeModulesDir: string +): Promise { + const copied = new Set() + const queue = [...packageNames] + while (queue.length > 0) { + const packageName = queue.pop() as string + if (copied.has(packageName)) { + continue + } + copied.add(packageName) + const sourceDir = findPackageDir(sourceStartDir, packageName) + if (!sourceDir) { + throw new Error(`Cannot locate daemon runtime dependency package: ${packageName}`) + } + const targetDir = join(targetNodeModulesDir, ...packageName.split('/')) + await mkdir(dirname(targetDir), { recursive: true }) + // Why: dereference resolves pnpm symlinks so the staged copy stays valid + // after the source store is pruned or the install dir is replaced. + await cp(sourceDir, targetDir, { recursive: true, dereference: true }) + try { + const packageJson = JSON.parse(await readFile(join(sourceDir, 'package.json'), 'utf8')) as { + dependencies?: Record + } + for (const dependency of Object.keys(packageJson.dependencies ?? {})) { + queue.push(dependency) + } + } catch { + // No package.json — nothing further to trace. + } + } +} + +/** Sibling files the Electron binary hard-requires to boot in + * ELECTRON_RUN_AS_NODE mode: ffmpeg.dll is its only statically imported + * non-system DLL, icudtl.dat feeds ICU init, and the two .bin files are the + * V8 startup snapshots. GPU DLLs (libEGL/libGLESv2/dx*/vk_*) and .pak UI + * resources are loaded only by renderer/GPU processes, never in node mode. + * Measured against Electron 42.3.3 win32-x64: this set is ~246.5MB (exe + * 231.5MB) vs ~306MB for all top-level install files. */ +const WINDOWS_EXEC_RUNTIME_FILES = [ + 'ffmpeg.dll', + 'icudtl.dat', + 'snapshot_blob.bin', + 'v8_context_snapshot.bin' +] + +async function stageWindowsExecutable(execPath: string, targetDir: string): Promise { + const sourceDir = dirname(execPath) + const stagedExecPath = join(targetDir, STAGED_DAEMON_EXECUTABLE_FILENAME) + await cp(execPath, stagedExecPath, { dereference: true }) + for (const fileName of WINDOWS_EXEC_RUNTIME_FILES) { + const source = join(sourceDir, fileName) + if (!existsSync(source)) { + // Why tolerated: a future Electron may drop one of these files; the + // fork ladder falls back to the default executable if the staged copy + // cannot boot, so a lean copy degrades gracefully instead of failing + // the whole staging. + continue + } + await cp(source, join(targetDir, fileName), { dereference: true }) + } + return stagedExecPath +} + +async function readStagingManifest(versionDir: string): Promise { + try { + const parsed = JSON.parse( + await readFile(join(versionDir, STAGING_MANIFEST_FILENAME), 'utf8') + ) as Partial + if (typeof parsed.appVersion !== 'string' || typeof parsed.entryFile !== 'string') { + return null + } + return { + appVersion: parsed.appVersion, + sourceEntryPath: typeof parsed.sourceEntryPath === 'string' ? parsed.sourceEntryPath : '', + entryFile: parsed.entryFile, + execFile: typeof parsed.execFile === 'string' ? parsed.execFile : null + } + } catch { + return null + } +} + +function stagedRuntimeFromManifest( + versionDir: string, + manifest: StagingManifest +): StagedDaemonRuntime | null { + const entryPath = join(versionDir, manifest.entryFile) + if (!existsSync(entryPath)) { + return null + } + const execPath = manifest.execFile ? join(versionDir, manifest.execFile) : null + if (execPath && !existsSync(execPath)) { + return null + } + return { entryPath, execPath, staged: true } +} + +export function getDaemonRuntimeVersionDir(stagingRoot: string, appVersion: string): string { + return join(stagingRoot, `v${appVersion}`) +} + +export async function stageDaemonRuntime( + input: DaemonRuntimeStagingInput +): Promise { + const platform = input.platform ?? process.platform + const log = input.log ?? ((message: string) => console.warn(message)) + const versionDir = getDaemonRuntimeVersionDir(input.stagingRoot, input.appVersion) + const fallback: StagedDaemonRuntime = { + entryPath: input.installEntryPath, + execPath: null, + staged: false + } + + try { + // Fast path: a completed staging for this app version is reused as-is so + // relaunches cost one manifest read instead of a copy. + const existingManifest = await readStagingManifest(versionDir) + if (existingManifest && existingManifest.appVersion === input.appVersion) { + const reused = stagedRuntimeFromManifest(versionDir, existingManifest) + if (reused) { + return reused + } + } + + const buildDir = `${versionDir}.staging-${process.pid}` + await rm(buildDir, { recursive: true, force: true }) + await mkdir(buildDir, { recursive: true }) + + const closure = await collectRelativeClosure(input.installEntryPath) + const entryDir = dirname(input.installEntryPath) + for (const relativeFile of closure.relativeFiles) { + const target = join(buildDir, relativeFile) + await mkdir(dirname(target), { recursive: true }) + await cp(join(entryDir, relativeFile), target, { dereference: true }) + } + if (closure.externalPackages.length > 0) { + await copyExternalPackages( + closure.externalPackages, + entryDir, + join(buildDir, 'node_modules') + ) + } + + let execFile: string | null = null + if (platform === 'win32' && input.execPath) { + await stageWindowsExecutable(input.execPath, buildDir) + execFile = STAGED_DAEMON_EXECUTABLE_FILENAME + } + + const manifest: StagingManifest = { + appVersion: input.appVersion, + sourceEntryPath: input.installEntryPath, + entryFile: basename(input.installEntryPath), + execFile + } + await writeFile(join(buildDir, STAGING_MANIFEST_FILENAME), JSON.stringify(manifest), 'utf8') + + // Why: build-then-rename keeps half-copied runtimes invisible; a crash + // mid-copy leaves only a .staging-* dir that the next attempt removes. + await rm(versionDir, { recursive: true, force: true }) + await rename(buildDir, versionDir) + + const staged = stagedRuntimeFromManifest(versionDir, manifest) + if (!staged) { + return fallback + } + return staged + } catch (error) { + log( + `[daemon] Failed to stage install-independent daemon runtime — falling back to the install dir entry: ${error instanceof Error ? error.message : String(error)}` + ) + return fallback + } +} + +export type PruneDaemonRuntimeStagingInput = { + stagingRoot: string + /** The version dir for the currently staged runtime; never pruned. */ + keepVersionDir: string + /** Entry paths recorded in daemon pid files. Any staged dir that still + * backs a (possibly live) daemon is preserved so adoption keeps working. */ + referencedEntryPaths: readonly string[] +} + +export async function pruneDaemonRuntimeStaging( + input: PruneDaemonRuntimeStagingInput +): Promise { + let entries + try { + entries = await readdir(input.stagingRoot, { withFileTypes: true }) + } catch { + return + } + const keep = resolve(input.keepVersionDir) + const referenced = input.referencedEntryPaths.map((entryPath) => resolve(entryPath)) + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + const dir = resolve(join(input.stagingRoot, entry.name)) + if (dir === keep) { + continue + } + if (referenced.some((entryPath) => entryPath.startsWith(dir + '/') || entryPath.startsWith(dir + '\\'))) { + continue + } + // Why best-effort: on Windows a still-running older daemon holds locks on + // its pty.node — the partial failure is harmless and retried next launch. + await rm(dir, { recursive: true, force: true }).catch(() => {}) + } +} diff --git a/src/main/daemon/daemon-socket-probe.ts b/src/main/daemon/daemon-socket-probe.ts new file mode 100644 index 00000000000..7b5e911d5a4 --- /dev/null +++ b/src/main/daemon/daemon-socket-probe.ts @@ -0,0 +1,51 @@ +import { existsSync } from 'node:fs' +import { connect } from 'node:net' + +export const DEFAULT_SOCKET_PROBE_TIMEOUT_MS = 1000 + +// Why: before spawning a new daemon, check if an existing one is alive by +// attempting a connection to the socket. If it connects, the daemon survived +// from a previous app session — reuse it instead of spawning. On Windows the +// endpoint is a named pipe with no filesystem presence, so there is no +// existsSync shortcut and a connect attempt is the only liveness signal. +export function probeSocket( + socketPath: string, + timeoutMs = DEFAULT_SOCKET_PROBE_TIMEOUT_MS +): Promise { + return new Promise((resolve) => { + if (process.platform !== 'win32' && !existsSync(socketPath)) { + resolve(false) + return + } + const sock = connect({ path: socketPath }) + let settled = false + let timer: ReturnType + 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 }) + }, timeoutMs) + sock.on('connect', onConnect) + sock.on('error', onError) + }) +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c7b40a929db..a204c54b071 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -106,6 +106,7 @@ import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync' import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { useRadixBodyPointerEventsRecovery } from './hooks/useRadixBodyPointerEventsRecovery' import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' +import { startSleepingAgentPeriodicCapture } from './lib/sleeping-agent-periodic-capture' import { buildWorkspaceSessionPayload, shouldPersistWorkspaceSession @@ -1306,6 +1307,15 @@ function App(): React.JSX.Element { return () => window.removeEventListener('beforeunload', captureAndFlush) }, []) + // Why: beforeunload never fires when the app is hard-killed (Windows NSIS + // updater kill sweep, crashes), so also refresh resumable-agent quit-state + // on a timer. The capture skips the store write when nothing changed. + useEffect(() => { + return startSleepingAgentPeriodicCapture({ + capture: () => useAppStore.getState().captureAllSleepingAgentSessions({ origin: 'live' }) + }) + }, []) + // Own the single window-close-request subscription at the always-mounted App // root. Why: the rich confirmation flow lives in Terminal, which is not // mounted on the no-workspace landing page (and is lazy-loaded elsewhere), so diff --git a/src/renderer/src/lib/sleeping-agent-periodic-capture.ts b/src/renderer/src/lib/sleeping-agent-periodic-capture.ts new file mode 100644 index 00000000000..cd042ba686c --- /dev/null +++ b/src/renderer/src/lib/sleeping-agent-periodic-capture.ts @@ -0,0 +1,22 @@ +export const SLEEPING_AGENT_PERIODIC_CAPTURE_INTERVAL_MS = 60_000 + +/** Drive the resumable-agent quit-state capture on a timer. + * + * Why: beforeunload never fires when the app is hard-killed — the Windows + * NSIS updater stops every Orca process before replacing the install dir, + * and crashes skip teardown entirely. A periodic capture keeps a recent + * resume record on disk for that case. A plain interval (not an on-change + * debounce) is enough because captureAllSleepingAgentSessions skips the + * store write when nothing changed, making idle ticks free. */ +export function startSleepingAgentPeriodicCapture(options: { + capture: () => void + intervalMs?: number +}): () => void { + const intervalMs = options.intervalMs ?? SLEEPING_AGENT_PERIODIC_CAPTURE_INTERVAL_MS + const timer: ReturnType = setInterval(() => { + options.capture() + }, intervalMs) + return () => { + clearInterval(timer) + } +} diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 855790fe43b..110a56202db 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -189,8 +189,10 @@ export type AgentStatusSlice = { captureSleepingAgentSessionsByWorktree: (worktreeId: string, paneKeys?: string[]) => void /** Capture resumable agent sessions across every worktree. Called from the - * quit flush so provider session ids survive an app restart. */ - captureAllSleepingAgentSessions: () => void + * quit flush (origin 'quit') and from the periodic safety-net capture + * (origin 'live') so provider session ids survive an app restart — even a + * hard kill (updater/installer/crash) that never fires beforeunload. */ + captureAllSleepingAgentSessions: (options?: { origin?: 'quit' | 'live' }) => void clearSleepingAgentSession: (paneKey: string) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void @@ -560,6 +562,33 @@ function recoveryRecordMatches( ) } +// Why capturedAt is ignored: the periodic safety-net capture runs on a timer, +// so identical session state must not count as a change — otherwise every +// tick would rewrite the record and trigger a session persist for nothing. +function sleepingRecordsEquivalent( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.origin === next.origin && + existing.agent === next.agent && + existing.worktreeId === next.worktreeId && + existing.tabId === next.tabId && + existing.providerSession.key === next.providerSession.key && + existing.providerSession.id === next.providerSession.id && + existing.prompt === next.prompt && + existing.state === next.state && + existing.updatedAt === next.updatedAt && + existing.terminalTitle === next.terminalTitle && + existing.lastAssistantMessage === next.lastAssistantMessage && + existing.interrupted === next.interrupted && + launchConfigsEqual(existing.launchConfig, next.launchConfig) + ) +} + function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig { return { ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), @@ -1981,7 +2010,7 @@ export const createAgentStatusSlice: StateCreator { + captureAllSleepingAgentSessions: (options) => { // Why: the quit flush must persist provider session ids for every live // agent pane — otherwise agents whose daemon PTYs die while the app is // closed have nothing to `--resume` from (#5232). Only live entries are @@ -2007,9 +2036,9 @@ export const createAgentStatusSlice: StateCreator