mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
Preserve live daemon PTYs across app updates (#4330)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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<void> }>
|
||||
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<void> }>
|
||||
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<void> }>
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,6 +144,23 @@ function createPreservedDaemonHandle(
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user