diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 3aa47ac5507..6a15f824653 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -771,6 +771,83 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) }) + it('preserves a daemon launched from another app path when it owns live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + return { + sessions: [ + { sessionId: 'wt-1@@live', isAlive: true }, + { sessionId: 'wt-1@@dead', isAlive: false } + ] + } + } + return {} + }) + const disconnectMock = vi.fn() + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: requestMock, + disconnect: disconnectMock + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') + + await launcher('/fake/socket', '/fake/token') + + expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token', + '/fake/app/out/main/daemon-entry.js' + ) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(disconnectMock).toHaveBeenCalledOnce() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('preserves a daemon launched from another app path when live session state cannot be verified', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + throw new Error('listSessions failed') + } + return {} + }) + const disconnectMock = vi.fn() + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: requestMock, + disconnect: disconnectMock + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') + + await launcher('/fake/socket', '/fake/token') + + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(disconnectMock).toHaveBeenCalledOnce() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + it('respawns instead of reusing a protocol-healthy daemon with broken macOS resolver state', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1113,4 +1190,46 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect.objectContaining({ detached: true }) ) }) + + it('preserves a packaged daemon that predates the current app bundle when it owns live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + return { + sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] + } + } + return {} + }) + const disconnectMock = vi.fn() + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: requestMock, + disconnect: disconnectMock + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + isPackagedMock.mockReturnValue(true) + isDaemonOlderThanPathMtimeMock.mockReturnValueOnce(true) + + await launcher('/fake/socket', '/fake/token') + + expect(isDaemonOlderThanPathMtimeMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token', + '/fake/app/out/main/daemon-entry.js' + ) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(disconnectMock).toHaveBeenCalledOnce() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index eaa14b32971..8087bf43ce0 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -144,6 +144,23 @@ function createPreservedDaemonHandle( } } +async function shouldPreserveDaemonWithLiveSessions( + socketPath: string, + tokenPath: string, + replacementLabel: string +): Promise { + 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() @@ -171,6 +188,14 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { const stalePackagedBundle = app.isPackaged && isDaemonOlderThanPathMtime(runtimeDir, socketPath, tokenPath, entryPath) 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' diff --git a/tests/e2e/daemon-live-session-preservation.spec.ts b/tests/e2e/daemon-live-session-preservation.spec.ts new file mode 100644 index 00000000000..4416f8bbc87 --- /dev/null +++ b/tests/e2e/daemon-live-session-preservation.spec.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs' +import path from 'path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +function daemonPidPath(userDataDir: string): string { + return path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`) +} + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync(daemonPidPath(userDataDir), 'utf8') + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +function spoofDaemonEntryPath(userDataDir: string): void { + const pidPath = daemonPidPath(userDataDir) + const parsed = JSON.parse(readFileSync(pidPath, 'utf8')) as Record + parsed.entryPath = '/tmp/orca-e2e-old-app/out/main/daemon-entry.js' + writeFileSync(pidPath, `${JSON.stringify(parsed)}\n`) +} + +async function bootstrapLaunch( + app: ElectronApplication, + repoPath: string +): Promise<{ ptyId: string; worktreeId: string }> { + const page = await app.firstWindow() + const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + const ptyId = await discoverActivePtyId(page) + return { ptyId, worktreeId } +} + +test.describe.configure({ mode: 'serial' }) + +test('preserves a live daemon PTY when the daemon launch identity is stale', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const { ptyId, worktreeId } = await bootstrapLaunch(firstApp, repoPath) + expect(ptyId).toContain(PTY_SESSION_ID_SEPARATOR) + + const marker = `DAEMON_LIVE_PRESERVE_${Date.now()}` + await execInTerminal(firstLaunch.page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(firstLaunch.page, marker) + + const daemonPidBefore = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // Why: this simulates the exact app-path mismatch that can happen after a + // dev-path change or app update while keeping the live daemon process intact. + spoofDaemonEntryPath(session.userDataDir) + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + await expect + .poll( + async () => secondLaunch.page.evaluate(() => window.__store?.getState().activeWorktreeId), + { + timeout: 10_000 + } + ) + .toBe(worktreeId) + await ensureTerminalVisible(secondLaunch.page) + await waitForActiveTerminalManager(secondLaunch.page, 30_000) + await waitForPaneCount(secondLaunch.page, 1, 30_000) + await waitForTerminalOutput(secondLaunch.page, marker, 15_000) + + expect(readDaemonPid(session.userDataDir)).toBe(daemonPidBefore) + expect(await getTerminalContent(secondLaunch.page)).not.toContain('--- session restored ---') + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +})