/* eslint-disable max-lines -- Why: covers daemon-init's full restart flow (7-step sequence per docs/daemon-staleness-ux.md §Phase 1 + coalescer); one describe block keeps shared mocks in one place. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' import { WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS, WEDGED_DAEMON_GRACE_RETRIES } from './daemon-init' const FAKE_USER_DATA_PATH = '/fake/userData' const FAKE_RUNTIME_DIR = join(FAKE_USER_DATA_PATH, 'daemon') const FAKE_APP_PATH = '/fake/app' const FAKE_APP_OUT_MAIN_PATH = join(FAKE_APP_PATH, 'out', 'main') const FAKE_DAEMON_ENTRY_PATH = join(FAKE_APP_OUT_MAIN_PATH, 'daemon-entry.js') // Why: we only care about runRestartDaemon's observable sequencing/identity invariants, so every non-daemon-init dependency is a minimal stub. const { getPathMock, getAppPathMock, isPackagedMock, probeSocketExistsMock, writeFileSyncMock, readFileSyncMock, unlinkSyncMock, netConnectMock, forkMock, checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getMacDaemonTccAttributionHealthMock, getDaemonLaunchIdentityMock, isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, readVerifiedDaemonPidMock, inspectDaemonPtyOwnershipMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, replaceDaemonPidFileMock, getDaemonCommandLineMock, unlinkOwnedDaemonPidFileMock, launchedStartedAtMs, readLaunchedDaemonIdentity, daemonClientMock, spawnerInstances, ensureRunningOverrides, adoptionLeaseReleases, lifecycleLeaseErrors, disconnectOnlyErrors, routerSubscriptionError, adapterInstances, defaultListSessionsSessions, listProcessesControl, getLocalPtyProviderMock, localFallbackProvider, setLocalPtyProviderMock, unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, trackDaemonRetiredMock } = vi.hoisted(() => { const getPathMock = vi.fn(() => '/fake/userData') const getAppPathMock = vi.fn(() => '/fake/app') const isPackagedMock = vi.fn(() => false) const probeSocketExistsMock = vi.fn((_path?: string) => false) const writeFileSyncMock = vi.fn() // Why: readFileSync throws by default so legacyDaemonProcessMayBeAlive treats every legacy pid file as unreadable (pre-fix cleanup behavior). const readFileSyncMock = vi.fn((): string => { throw new Error('ENOENT') }) const unlinkSyncMock = vi.fn() const forkMock = vi.fn() const netConnectMock = vi.fn(() => { // Why: stub the socket so probeSocket's 'error' path fires and cleanupDaemonForProtocol's alive=false branch runs without side effects. const handlers: Record void)[]> = { connect: [], error: [] } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) if (event === 'error') { // Fire after microtask so destroy()/resolve ordering matches real net queueMicrotask(() => cb()) } return this }, removeListener(event: string, cb: () => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, destroy() {} } }) const checkDaemonHealthMock = vi.fn(async () => 'healthy') const healthCheckDaemonMock = vi.fn(async () => true) const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getMacDaemonTccAttributionHealthMock = vi.fn(async () => 'unknown') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const isDaemonStaleForCurrentBundleMock = vi.fn(() => false) const inspectDaemonPtyOwnershipMock = vi.fn( async (): Promise<'owns-live-ptys' | 'no-live-ptys' | 'unknown'> => 'unknown' ) const readVerifiedDaemonPidMock = vi.fn(async (): Promise<{ pid: number } | null> => null) const killStaleDaemonMock = vi.fn(async () => ({ killed: true, liveOwnerSurvived: false })) const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000) const parseDaemonPidFileMock = vi.fn( (): { pid: number; startedAtMs: number | null } | null => null ) const replaceDaemonPidFileMock = vi.fn(() => true) const getDaemonCommandLineMock = vi.fn(async (_pid: number): Promise => null) const unlinkOwnedDaemonPidFileMock = vi.fn(() => true) const launchedStartedAtMs = { current: 1_000_000 } const readLaunchedDaemonIdentity = () => { const args = forkMock.mock.calls.at(-1)?.[1] const child = forkMock.mock.results.at(-1)?.value as { pid?: unknown } | undefined const launchNonceIndex = Array.isArray(args) ? args.indexOf('--launch-nonce') : -1 const launchNonce = launchNonceIndex >= 0 ? args[launchNonceIndex + 1] : undefined return typeof child?.pid === 'number' && typeof launchNonce === 'string' ? { pid: child.pid, startedAtMs: launchedStartedAtMs.current, launchNonce } : null } const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } }) // Why: every DaemonSpawner pushes here so assertions can check the *same* spawner was reused across restart. const spawnerInstances: MockSpawner[] = [] const ensureRunningOverrides: (() => Promise<{ socketPath: string tokenPath: string mode?: 'degraded-new-pty-fallback' }>)[] = [] const adoptionLeaseReleases: ReturnType[] = [] const lifecycleLeaseErrors: Error[] = [] const disconnectOnlyErrors: Error[] = [] const routerSubscriptionError: { current: Error | null } = { current: null } // Same for DaemonPtyAdapter — tests assert the replacement adapter is fresh but its respawn closure targets the *original* spawner. const adapterInstances: MockAdapter[] = [] // Why: adapters are built inside initDaemonPtyProvider, so tests set this before init to make listSessions report live sessions. const defaultListSessionsSessions: { sessionId: string }[] = [] const listProcessesControl: { current: null | (() => Promise<{ sessionId: string }[]>) } = { current: null } const localFallbackProvider = { routesFreshSpawnsToLocalProvider: undefined, spawn: vi.fn(async (opts: { sessionId?: string }) => ({ id: opts.sessionId ?? 'local-fallback-pty' })), attach: vi.fn(async () => {}), hasPty: vi.fn(() => false), write: vi.fn(), resize: vi.fn(), shutdown: vi.fn(async () => {}), sendSignal: vi.fn(async () => {}), getCwd: vi.fn(async () => ''), getInitialCwd: vi.fn(async () => ''), clearBuffer: vi.fn(async () => {}), acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), serialize: vi.fn(async () => '{}'), revive: vi.fn(async () => {}), listProcesses: vi.fn(async () => []), getDefaultShell: vi.fn(async () => '/bin/zsh'), getProfiles: vi.fn(async () => []), onData: vi.fn(() => () => {}), onReplay: vi.fn(() => () => {}), onExit: vi.fn(() => () => {}) } const getLocalPtyProviderMock = vi.fn(() => localFallbackProvider) const setLocalPtyProviderMock = vi.fn() const unbindLocalProviderListenersMock = vi.fn() const rebindLocalProviderListenersMock = vi.fn() const trackDaemonReplacedMock = vi.fn() const trackDaemonRetiredMock = vi.fn() return { getPathMock, getAppPathMock, isPackagedMock, probeSocketExistsMock, writeFileSyncMock, readFileSyncMock, unlinkSyncMock, netConnectMock, forkMock, checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getMacDaemonTccAttributionHealthMock, getDaemonLaunchIdentityMock, isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, readVerifiedDaemonPidMock, inspectDaemonPtyOwnershipMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, replaceDaemonPidFileMock, getDaemonCommandLineMock, unlinkOwnedDaemonPidFileMock, launchedStartedAtMs, readLaunchedDaemonIdentity, daemonClientMock, spawnerInstances, ensureRunningOverrides, adoptionLeaseReleases, lifecycleLeaseErrors, disconnectOnlyErrors, routerSubscriptionError, adapterInstances, defaultListSessionsSessions, listProcessesControl, getLocalPtyProviderMock, localFallbackProvider, setLocalPtyProviderMock, unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, trackDaemonRetiredMock } }) type MockSpawner = { ensureRunning: ReturnType resetHandle: ReturnType shutdown: ReturnType getHandle: ReturnType launcher: unknown } type MockAdapter = { protocolVersion: number options: { socketPath: string tokenPath: string historyPath?: string respawn?: (reason: 'daemon_died' | 'unhealthy_resolver') => Promise protocolVersion?: number } getActiveSessionIds: ReturnType fanoutSyntheticExits: ReturnType listProcesses: ReturnType listSessions: ReturnType establishLifecycleLease: ReturnType shutdown: ReturnType dispose: ReturnType disconnectOnly: ReturnType onData: ReturnType onExit: ReturnType // Why: the router calls onData/onExit on each adapter; the stub returns a no-op unsubscribe so router subscription doesn't explode. callOrder: string[] } vi.mock('electron', () => ({ app: { get isPackaged() { return isPackagedMock() }, getPath: getPathMock, getAppPath: getAppPathMock, getVersion: () => '1.2.3' } })) // Map the existing boolean socket double onto the canonical three-valued probe: // present ⇒ something is serving, absent ⇒ positively dead. vi.mock('./daemon-live-pty-evidence', () => ({ inspectDaemonPtyOwnership: inspectDaemonPtyOwnershipMock })) vi.mock('./daemon-endpoint-probe', async (importOriginal) => ({ ...(await importOriginal>()), probeSocketConnect: async (p: string) => (probeSocketExistsMock(p) ? 'connected' : 'missing') })) vi.mock('fs', () => ({ mkdirSync: vi.fn(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), unlinkSync: unlinkSyncMock, readFileSync: readFileSyncMock, writeFileSync: writeFileSyncMock })) vi.mock('child_process', async (importOriginal) => ({ ...(await importOriginal>()), fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock, getDaemonCommandLine: getDaemonCommandLineMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock, healthCheckDaemon: healthCheckDaemonMock, isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock, killStaleDaemon: killStaleDaemonMock, readVerifiedDaemonPid: readVerifiedDaemonPidMock, getProcessStartedAtMs: getProcessStartedAtMsMock, parseDaemonPidFile: parseDaemonPidFileMock })) vi.mock('./client', () => ({ // Mirror ensureConnected onto the bounded variant so every existing double keeps its // behaviour — including the ones whose whole point is that connecting throws. The budget is // forwarded rather than dropped so a test can see what the launcher was willing to wait. DaemonClient: function DaemonClientDouble(...args: unknown[]) { const instance = (daemonClientMock as unknown as (...a: unknown[]) => Record)( ...args ) if (instance && typeof instance === 'object' && !('ensureConnectedWithin' in instance)) { instance.ensureConnectedWithin = (budgetMs?: number) => (instance.ensureConnected as (ms?: number) => unknown)(budgetMs) } return instance } })) vi.mock('./daemon-lifecycle-event', () => ({ trackDaemonReplaced: trackDaemonReplacedMock, trackDaemonRetired: trackDaemonRetiredMock })) vi.mock('./daemon-spawner', () => ({ DaemonSpawner: class MockDaemonSpawner { readonly launcher: unknown readonly ensureRunning: ReturnType readonly resetHandle: ReturnType readonly shutdown: ReturnType readonly getHandle: ReturnType private socketCounter: number private handle: { mode?: 'degraded-new-pty-fallback' releaseAdoptionLease?: () => void shutdown: () => Promise } | null constructor(opts: { runtimeDir: string; launcher: unknown }) { this.launcher = opts.launcher this.socketCounter = 0 this.handle = null // Why: each ensureRunning bumps a counter into socketPath so tests can tell the replacement adapter used the second call, not the first. this.ensureRunning = vi.fn(async () => { const override = ensureRunningOverrides.shift() if (override) { const result = await override() const releaseAdoptionLease = vi.fn() adoptionLeaseReleases.push(releaseAdoptionLease) this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) } if (result.mode) { this.handle.mode = result.mode } return { socketPath: result.socketPath, tokenPath: result.tokenPath } } this.socketCounter += 1 const releaseAdoptionLease = vi.fn() adoptionLeaseReleases.push(releaseAdoptionLease) this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) } return { socketPath: `/fake/socket-${this.socketCounter}`, tokenPath: `/fake/token-${this.socketCounter}` } }) this.resetHandle = vi.fn() this.shutdown = vi.fn(async () => {}) this.getHandle = vi.fn(() => this.handle) spawnerInstances.push(this as unknown as MockSpawner) } }, getDaemonSocketPath: (_dir: string, version?: number) => `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.sock`, getDaemonTokenPath: (_dir: string, version?: number) => `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.token`, getDaemonPidPath: (_dir: string, version?: number) => `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`, serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj), replaceDaemonPidFile: replaceDaemonPidFileMock, unlinkOwnedDaemonPidFile: unlinkOwnedDaemonPidFileMock })) vi.mock('./daemon-pty-adapter', () => ({ DaemonPtyAdapter: class MockDaemonPtyAdapter { readonly protocolVersion: number readonly options: MockAdapter['options'] readonly getActiveSessionIds: ReturnType readonly fanoutSyntheticExits: ReturnType readonly listProcesses: ReturnType readonly listSessions: ReturnType readonly establishLifecycleLease: ReturnType readonly shutdown: ReturnType readonly dispose: ReturnType readonly disconnectOnly: ReturnType readonly onData: ReturnType readonly onExit: ReturnType readonly callOrder: string[] constructor(opts: MockAdapter['options']) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION this.options = opts this.callOrder = [] this.getActiveSessionIds = vi.fn(() => [] as string[]) this.fanoutSyntheticExits = vi.fn(() => { this.callOrder.push('fanoutSyntheticExits') }) this.listProcesses = vi.fn(async () => listProcessesControl.current ? listProcessesControl.current() : [] ) this.listSessions = vi.fn(async () => [...defaultListSessionsSessions]) const lifecycleLeaseError = lifecycleLeaseErrors.shift() this.establishLifecycleLease = vi.fn(async () => { if (lifecycleLeaseError) { throw lifecycleLeaseError } }) this.shutdown = vi.fn(async () => {}) this.dispose = vi.fn() const disconnectOnlyError = disconnectOnlyErrors.shift() this.disconnectOnly = vi.fn(async () => { if (disconnectOnlyError) { throw disconnectOnlyError } }) this.onData = vi.fn(() => { if (routerSubscriptionError.current) { const error = routerSubscriptionError.current routerSubscriptionError.current = null throw error } return () => {} }) this.onExit = vi.fn(() => () => {}) adapterInstances.push(this as unknown as MockAdapter) } } })) vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: getLocalPtyProviderMock, setLocalPtyProvider: setLocalPtyProviderMock, unbindLocalProviderListeners: unbindLocalProviderListenersMock, rebindLocalProviderListeners: rebindLocalProviderListenersMock })) async function importFresh() { vi.resetModules() spawnerInstances.length = 0 ensureRunningOverrides.length = 0 adoptionLeaseReleases.length = 0 lifecycleLeaseErrors.length = 0 disconnectOnlyErrors.length = 0 routerSubscriptionError.current = null adapterInstances.length = 0 defaultListSessionsSessions.length = 0 listProcessesControl.current = null getLocalPtyProviderMock.mockClear() localFallbackProvider.spawn.mockClear() localFallbackProvider.write.mockClear() localFallbackProvider.onData.mockClear() localFallbackProvider.onExit.mockClear() setLocalPtyProviderMock.mockClear() unbindLocalProviderListenersMock.mockClear() rebindLocalProviderListenersMock.mockClear() trackDaemonReplacedMock.mockClear() trackDaemonRetiredMock.mockClear() checkDaemonHealthMock.mockClear() checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() healthCheckDaemonMock.mockResolvedValue(true) getMacDaemonSystemResolverHealthMock.mockReset() getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy') getMacDaemonTccAttributionHealthMock.mockReset() getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown') getDaemonLaunchIdentityMock.mockClear() isDaemonStaleForCurrentBundleMock.mockReset() isDaemonStaleForCurrentBundleMock.mockReturnValue(false) // mockReset (not mockClear) also drops an unconsumed *Once queue, so a test that bails early // can't leak a queued false into the next test's confirmedReplacement gate. killStaleDaemonMock.mockReset() killStaleDaemonMock.mockResolvedValue({ killed: true, liveOwnerSurvived: false }) getAppPathMock.mockReset() getAppPathMock.mockReturnValue('/fake/app') forkMock.mockReset() isPackagedMock.mockReset() isPackagedMock.mockReturnValue(false) daemonClientMock.mockReset() daemonClientMock.mockImplementation(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } }) probeSocketExistsMock.mockClear() writeFileSyncMock.mockClear() readFileSyncMock.mockReset() readFileSyncMock.mockImplementation(() => { throw new Error('ENOENT') }) unlinkSyncMock.mockClear() parseDaemonPidFileMock.mockReset() parseDaemonPidFileMock.mockReturnValue(null) unlinkOwnedDaemonPidFileMock.mockReset() unlinkOwnedDaemonPidFileMock.mockReturnValue(true) launchedStartedAtMs.current = 1_000_000 getProcessStartedAtMsMock.mockReset() getProcessStartedAtMsMock.mockReturnValue(1_000_000) // Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer. return import('./daemon-init') } function mockConnectedAdoptionClientOnce(): void { daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(), disconnect: vi.fn() } }) } function mockOnlyDaemonSocketAlive(socketSuffix: string): void { netConnectMock.mockImplementation((options?: { path?: string }) => { const live = options?.path?.endsWith(socketSuffix) ?? false const handlers: Record void)[]> = { connect: [], error: [] } return { on(event: string, callback: () => void) { handlers[event]?.push(callback) if ((live && event === 'connect') || (!live && event === 'error')) { queueMicrotask(() => callback()) } return this }, removeListener(event: string, callback: () => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] return this }, destroy() {} } }) } describe('daemon-init: runRestartDaemon (7-step sequence)', () => { beforeEach(() => { probeSocketExistsMock.mockReturnValue(false) netConnectMock.mockReset() netConnectMock.mockImplementation(() => { const handlers: Record void)[]> = { connect: [], error: [] } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) if (event === 'error') { queueMicrotask(() => cb()) } return this }, removeListener(event: string, cb: () => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, destroy() {} } }) }) afterEach(() => { vi.clearAllMocks() // Why restore too: tests here spy on Date.now, and clearAllMocks keeps the fake // implementation. A failure before an inline restore would freeze the clock for every // later test in the file, turning one red into a cascade. vi.restoreAllMocks() }) it('re-binds listeners after the first daemon provider is installed', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(1) expect(rebindLocalProviderListenersMock).toHaveBeenCalledTimes(1) expect(rebindLocalProviderListenersMock.mock.invocationCallOrder[0]).toBeGreaterThan( setLocalPtyProviderMock.mock.invocationCallOrder[0] ) expect(adapterInstances[0].establishLifecycleLease).toHaveBeenCalledOnce() expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( setLocalPtyProviderMock.mock.invocationCallOrder[0] ) expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( adoptionLeaseReleases[0].mock.invocationCallOrder[0] ) }) it('uses daemon-owned idle retirement when a fresh launch fails permanent adoption', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ socketPath: '/fake/launched-socket', tokenPath: '/fake/launched-token' })) lifecycleLeaseErrors.push(new Error('lease identity mismatch')) await expect(mod.initDaemonPtyProvider()).rejects.toThrow('lease identity mismatch') expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() expect(adapterInstances[0].dispose).not.toHaveBeenCalled() expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( adoptionLeaseReleases[0].mock.invocationCallOrder[0] ) expect(setLocalPtyProviderMock).not.toHaveBeenCalled() }) it('does not kill a preserved daemon when startup lease acquisition fails', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ socketPath: '/fake/preserved-socket', tokenPath: '/fake/preserved-token' })) lifecycleLeaseErrors.push(new Error('preserved lease failed')) await expect(mod.initDaemonPtyProvider()).rejects.toThrow('preserved lease failed') expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() expect(setLocalPtyProviderMock).not.toHaveBeenCalled() }) it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => { const mod = await importFresh() // Why: live-pty-gate is intentionally unmocked — import from the same fresh registry so gate state matches daemon-init's. const gate = await import('../claude-accounts/live-pty-gate') defaultListSessionsSessions.push({ sessionId: 'claude-alive' }) gate.seedLiveClaudePtysFromPersistence(['claude-alive', 'claude-dead']) try { await mod.initDaemonPtyProvider() expect(gate.hasLiveClaudePtys()).toBe(true) gate.markClaudePtyExited('claude-alive') // Why: proves 'claude-dead' was released by the daemon reconcile — the surviving session held the gate alone. expect(gate.hasLiveClaudePtys()).toBe(false) } finally { gate.markClaudePtyExited('claude-alive') gate.markClaudePtyExited('claude-dead') } }) it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => { const mod = await importFresh() let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void ensureRunningOverrides.push( () => new Promise((resolve) => { resolveEnsureRunning = resolve }) ) const abortController = new AbortController() const started = mod.initDaemonPtyProvider(abortController.signal) await Promise.resolve() expect(spawnerInstances).toHaveLength(1) expect(spawnerInstances[0].ensureRunning).toHaveBeenCalledTimes(1) abortController.abort() resolveEnsureRunning({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' }) await started expect(adapterInstances).toHaveLength(1) expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() expect(adapterInstances[0].establishLifecycleLease).not.toHaveBeenCalled() expect(setLocalPtyProviderMock).not.toHaveBeenCalled() expect(rebindLocalProviderListenersMock).not.toHaveBeenCalled() expect(mod.getDaemonProvider()).toBeNull() }) it('disconnects uninstalled adapter leases when startup aborts during legacy discovery', async () => { const mod = await importFresh() probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false) mockOnlyDaemonSocketAlive('daemon-v9.sock') let resolveDiscovery!: (sessions: { sessionId: string }[]) => void const discovery = new Promise<{ sessionId: string }[]>((resolve) => { resolveDiscovery = resolve }) listProcessesControl.current = () => discovery const abortController = new AbortController() const started = mod.initDaemonPtyProvider(abortController.signal) await vi.waitFor(() => { expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true) expect( adapterInstances.some((instance) => instance.listProcesses.mock.calls.length > 0) ).toBe(true) }) abortController.abort() resolveDiscovery([]) await started expect(adapterInstances).toHaveLength(2) expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() expect(setLocalPtyProviderMock).not.toHaveBeenCalled() expect(mod.getDaemonProvider()).toBeNull() }) it('retains every adapter cleanup failure when legacy router setup aborts', async () => { const mod = await importFresh() probeSocketExistsMock.mockImplementation( (path?: string) => path?.endsWith('daemon-v9.sock') ?? false ) mockOnlyDaemonSocketAlive('daemon-v9.sock') const discoveryError = new Error('router subscription failed') const currentCleanupError = new Error('current cleanup failed') const legacyCleanupError = new Error('legacy cleanup failed') routerSubscriptionError.current = discoveryError disconnectOnlyErrors.push(currentCleanupError, legacyCleanupError) const error = await mod.initDaemonPtyProvider().catch((caught: unknown) => caught) expect(error).toBeInstanceOf(AggregateError) const topLevelErrors = (error as AggregateError).errors expect(topLevelErrors[0]).toBe(discoveryError) expect(topLevelErrors[1]).toBeInstanceOf(AggregateError) expect((topLevelErrors[1] as AggregateError).errors).toEqual([ legacyCleanupError, currentCleanupError ]) expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() }) it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ socketPath: '/fake/degraded-socket', tokenPath: '/fake/degraded-token', mode: 'degraded-new-pty-fallback' })) await mod.initDaemonPtyProvider() const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') const provider = mod.getDaemonProvider() expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider) expect(getLocalPtyProviderMock).toHaveBeenCalledOnce() expect(setLocalPtyProviderMock).toHaveBeenCalledWith(provider) const result = await provider!.spawn({ cols: 80, rows: 24 }) expect(result.id).toBe('local-fallback-pty') expect(localFallbackProvider.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) expect(adapterInstances[0].listProcesses).toHaveBeenCalled() }) it('rechecks the preserved daemon endpoint before recovering fresh-spawn routing', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ socketPath: '/fake/degraded-socket', tokenPath: '/fake/degraded-token', mode: 'degraded-new-pty-fallback' })) await mod.initDaemonPtyProvider() checkDaemonHealthMock.mockClear() const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') const provider = mod.getDaemonProvider() expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider) const degradedProvider = provider as InstanceType await expect(degradedProvider.recoverFreshSpawnRouting()).resolves.toBe(true) expect(checkDaemonHealthMock).toHaveBeenCalledWith( '/fake/degraded-socket', '/fake/degraded-token' ) expect(degradedProvider.routesFreshSpawnsToLocalProvider).toBeUndefined() }) it('fans pty:exit for every active session *before* unbinding listeners, and killedCount is captured pre-fanout', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() // Why: seed active sessions and mock the snapshot-then-clear so a regression measuring killedCount *after* fanout surfaces as 0. const originalAdapter = adapterInstances[0] let activeIds = ['sess-a', 'sess-b', 'sess-c'] originalAdapter.getActiveSessionIds.mockImplementation(() => [...activeIds]) const order: string[] = [] originalAdapter.fanoutSyntheticExits.mockImplementation(() => { order.push('fanout') activeIds = [] }) unbindLocalProviderListenersMock.mockImplementation(() => { order.push('unbind') }) const result = await mod.restartDaemon() // killedCount must be 3 — proves the count was taken *before* fanout cleared the set (a swapped-order bug reports 0). expect(result.killedCount).toBe(3) expect(originalAdapter.fanoutSyntheticExits).toHaveBeenCalledWith(-1) // Ordering invariant: synthetic exits must reach the renderer *before* listeners are torn down (Step 1 before 2). expect(order).toEqual(['fanout', 'unbind']) }) it('uses daemon-owned idle retirement after a failed manual-restart adoption', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const originalProvider = mod.getDaemonProvider() ensureRunningOverrides.push(async () => ({ socketPath: '/fake/restart-failure-socket', tokenPath: '/fake/restart-failure-token' })) lifecycleLeaseErrors.push(new Error('restart lease failed')) await expect(mod.restartDaemon()).rejects.toThrow('restart lease failed') expect(adoptionLeaseReleases[1]).toHaveBeenCalledOnce() expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() expect(mod.getDaemonProvider()).toBe(originalProvider) expect(unbindLocalProviderListenersMock).toHaveBeenCalledOnce() expect(rebindLocalProviderListenersMock).toHaveBeenCalledTimes(2) }) it('fans exits for preserved degraded current-daemon sessions during restart', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ socketPath: '/fake/degraded-socket', tokenPath: '/fake/degraded-token', mode: 'degraded-new-pty-fallback' })) await mod.initDaemonPtyProvider() const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') const provider = mod.getDaemonProvider() expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider) const degradedProvider = provider as InstanceType const originalAdapter = adapterInstances[0] originalAdapter.listProcesses.mockResolvedValueOnce([ { id: 'preserved-current-session', cwd: '/repo', title: 'shell' } ]) await degradedProvider.discoverDaemonSessions() const order: string[] = [] degradedProvider.onExit((payload) => { if (payload.id === 'preserved-current-session') { order.push('degraded-fanout') } }) originalAdapter.fanoutSyntheticExits.mockImplementation(() => { order.push('adapter-fanout') }) unbindLocalProviderListenersMock.mockImplementation(() => { order.push('unbind') }) const result = await mod.restartDaemon() expect(result.killedCount).toBe(1) expect(order).toEqual(['adapter-fanout', 'degraded-fanout', 'unbind']) }) it('reuses the existing DaemonSpawner across restart (resetHandle + ensureRunning on same instance)', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() expect(spawnerInstances).toHaveLength(1) const originalSpawner = spawnerInstances[0] expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(1) await mod.restartDaemon() // No second DaemonSpawner was constructed — restart uses the one from init. expect(spawnerInstances).toHaveLength(1) expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1) expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2) }) it('builds a fresh adapter whose respawn callback closes over the same spawner', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const originalSpawner = spawnerInstances[0] const originalAdapter = adapterInstances[0] await mod.restartDaemon() // A new adapter was constructed against the replacement daemon's socket. expect(adapterInstances).toHaveLength(2) const replacementAdapter = adapterInstances[1] expect(replacementAdapter).not.toBe(originalAdapter) expect(replacementAdapter.options.socketPath).toBe('/fake/socket-2') expect(replacementAdapter.options.tokenPath).toBe('/fake/token-2') // The replacement adapter's respawn closure must drive the *same* original spawner (see daemon-init.ts step 5). originalSpawner.resetHandle.mockClear() originalSpawner.ensureRunning.mockClear() await replacementAdapter.options.respawn?.('daemon_died') expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1) expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(1) // STA-2376: death → respawn retires, exactly once. expect(trackDaemonRetiredMock).toHaveBeenCalledTimes(1) expect(trackDaemonRetiredMock).toHaveBeenCalledWith('died_respawn') trackDaemonRetiredMock.mockClear() trackDaemonReplacedMock.mockClear() // STA-2376: the resolver respawn attributes rather than emits — the launch it triggers reports it. // Emitting here too would double-count, and would fire before the outcome is known. await replacementAdapter.options.respawn?.('unhealthy_resolver') expect(trackDaemonRetiredMock).not.toHaveBeenCalled() expect(trackDaemonReplacedMock).not.toHaveBeenCalled() // Still only one spawner in the whole test — nobody new was constructed. expect(spawnerInstances).toHaveLength(1) }) it('swaps the module-level adapter and re-binds listeners after the new provider is installed', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() // initDaemonPtyProvider calls setLocalPtyProvider once with the original. expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(1) const originalProvider = setLocalPtyProviderMock.mock.calls[0][0] expect(originalProvider).toBe(adapterInstances[0]) expect(mod.getDaemonProvider()).toBe(originalProvider) await mod.restartDaemon() const replacementAdapter = adapterInstances[1] // Second call: swap to the replacement provider (Step 6). expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(2) expect(setLocalPtyProviderMock.mock.calls[1][0]).toBe(replacementAdapter) expect(mod.getDaemonProvider()).toBe(replacementAdapter) // Step 7: rebind must run *after* Step 6 (the provider swap). const rebindOrder = rebindLocalProviderListenersMock.mock.invocationCallOrder.at(-1) ?? -1 const swapOrder = setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) ?? -1 expect(rebindOrder).toBeGreaterThan(swapOrder) }) // STA-2376: a manual restart kills the daemon while the outgoing adapter is still live, so a pane // respawning on its synthetic exit reaches the death path for a user action. That must not land in // the crash bucket. Driven from inside the restart's ensureRunning so restartInFlight is genuinely // set, rather than asserting the guard against a flag the test poked itself. it('does not report a retirement for a death observed during a manual restart', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const outgoingRespawn = adapterInstances[0].options.respawn trackDaemonRetiredMock.mockClear() let respawnedMidRestart = false ensureRunningOverrides.push(async () => { await outgoingRespawn?.('daemon_died') respawnedMidRestart = true return { socketPath: '/fake/restarted-socket', tokenPath: '/fake/restarted-token' } }) await mod.restartDaemon() expect(respawnedMidRestart).toBe(true) expect(trackDaemonRetiredMock).not.toHaveBeenCalled() // The same closure still retires once the restart has settled, so the guard is scoped, not permanent. await outgoingRespawn?.('daemon_died') expect(trackDaemonRetiredMock).toHaveBeenCalledTimes(1) expect(trackDaemonRetiredMock).toHaveBeenCalledWith('died_respawn') // The restart installs its own adapter, whose closure is a second copy of the guard — and the one // that actually runs in the field from the second restart onward, since the first adapter is gone. const restartedRespawn = adapterInstances[1].options.respawn trackDaemonRetiredMock.mockClear() let respawnedMidSecondRestart = false ensureRunningOverrides.push(async () => { await restartedRespawn?.('daemon_died') respawnedMidSecondRestart = true return { socketPath: '/fake/restarted-socket-2', tokenPath: '/fake/restarted-token-2' } }) await mod.restartDaemon() expect(respawnedMidSecondRestart).toBe(true) expect(trackDaemonRetiredMock).not.toHaveBeenCalled() }) it('preserves legacy adapter instances by identity, drains outgoing router via disposeRouterOnly, and re-discovers legacy sessions on the new router', async () => { const mod = await importFresh() // Why: bypass createLegacyDaemonAdapters' socket-probe machinery — directly construct a router with a legacy adapter and install it. await mod.initDaemonPtyProvider() const { DaemonPtyRouter } = await import('./daemon-pty-router') const { DaemonPtyAdapter } = await import('./daemon-pty-adapter') const currentAtConstruction = adapterInstances[0] const legacyAdapter = new DaemonPtyAdapter({ socketPath: '/fake/legacy.sock', tokenPath: '/fake/legacy.token', protocolVersion: 3 }) const routerWithLegacy = new DaemonPtyRouter({ current: currentAtConstruction as unknown as InstanceType, legacy: [legacyAdapter as unknown as InstanceType] }) // Why: spy on the outgoing router's disposeRouterOnly — adapter survival alone wouldn't catch a no-op that leaks listeners. const disposeRouterOnlySpy = vi.spyOn(routerWithLegacy, 'disposeRouterOnly') const oldRouterDispose = vi.spyOn(routerWithLegacy, 'dispose') mod.replaceDaemonProvider(routerWithLegacy) await mod.restartDaemon() const provider = mod.getDaemonProvider() expect(provider).toBeInstanceOf(DaemonPtyRouter) const newRouter = provider as InstanceType expect(newRouter).not.toBe(routerWithLegacy) // Legacy adapter is preserved by identity — not reconstructed, copied, or disposed. const legacies = newRouter.getLegacyAdapters() expect(legacies).toHaveLength(1) expect(legacies[0]).toBe(legacyAdapter) expect(legacyAdapter.dispose).not.toHaveBeenCalled() // Router drained via disposeRouterOnly (router-only teardown), so legacy adapters' connections are untouched. expect(legacyAdapter.disconnectOnly).not.toHaveBeenCalled() // disposeRouterOnly drained subscriptions but did NOT dispose the adapters behind it. expect(disposeRouterOnlySpy).toHaveBeenCalledTimes(1) expect(oldRouterDispose).not.toHaveBeenCalled() // The replacement router re-runs discovery so spawns for a surviving legacy sessionId still route to the legacy adapter. expect(legacyAdapter.listProcesses).toHaveBeenCalled() }) it('routes affected v9 daemon sessions through a legacy adapter on launch', async () => { const mod = await importFresh() probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false) mockOnlyDaemonSocketAlive('daemon-v9.sock') await mod.initDaemonPtyProvider() const { DaemonPtyRouter } = await import('./daemon-pty-router') expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyRouter) expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true) }) it('restart path with no legacy adapters yields a bare DaemonPtyAdapter (not wrapped in a router)', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() // initDaemonPtyProvider yields a bare adapter when no legacy adapters exist — confirm that shape persists across restart. const { DaemonPtyAdapter } = await import('./daemon-pty-adapter') const { DaemonPtyRouter } = await import('./daemon-pty-router') expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyAdapter) await mod.restartDaemon() expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyAdapter) expect(mod.getDaemonProvider()).not.toBeInstanceOf(DaemonPtyRouter) }) it('orders Step 3 (cleanup) → Step 4 (resetHandle + ensureRunning) → Step 5 (new adapter) → Step 6 (replaceProvider) → Step 7 (rebind)', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const originalSpawner = spawnerInstances[0] const originalAdapter = adapterInstances[0] // Build an ordered trace by stamping each step; cleanup has no observable in the default probeSocket=false path, so instrument resetHandle instead. const trace: string[] = [] originalAdapter.fanoutSyntheticExits.mockImplementation(() => trace.push('fanout')) unbindLocalProviderListenersMock.mockImplementation(() => trace.push('unbind')) originalSpawner.resetHandle.mockImplementation(() => trace.push('resetHandle')) const originalEnsureRunning = originalSpawner.ensureRunning originalSpawner.ensureRunning.mockImplementation(async () => { trace.push('ensureRunning') return { socketPath: '/fake/socket-2', tokenPath: '/fake/token-2' } }) setLocalPtyProviderMock.mockImplementation(() => trace.push('replaceProvider')) rebindLocalProviderListenersMock.mockImplementation(() => trace.push('rebind')) await mod.restartDaemon() void originalEnsureRunning // keep ref so tslint doesn't complain // Full 7-step order; Step 3 (cleanup) has no observable in the dead-socket branch, so it's pinned implicitly by resetHandle running after unbind. expect(trace).toEqual([ 'fanout', 'unbind', 'resetHandle', 'ensureRunning', 'replaceProvider', 'rebind' ]) // A fresh adapter built between ensureRunning and replaceProvider (Step 5 before 6); its Step-4 socketPath proves the ordering. expect(adapterInstances).toHaveLength(2) expect(adapterInstances[1].options.socketPath).toBe('/fake/socket-2') expect(adapterInstances[1].establishLifecycleLease).toHaveBeenCalledOnce() expect(adapterInstances[1].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) as number ) }) it('exercises the alive-daemon cleanup path: issues shutdown RPC via DaemonClient before spawning a replacement', async () => { // Why: default probeSocket=false skips Step 3's shutdown RPC; flip the socket "alive" to cover the shutdown-RPC-succeeded branch. const requestMock = vi.fn(async (method: string) => { if (method === 'listSessions') { return { sessions: [{ sessionId: 'live-1', isAlive: true }] } } // `shutdown` RPC — daemon exits before reply lands; return undefined. return undefined }) const ensureConnectedMock = vi.fn(async () => {}) const disconnectMock = vi.fn() const mod = await importFresh() await mod.initDaemonPtyProvider() daemonClientMock.mockImplementationOnce(function MockDaemonClientForShutdown() { return { ensureConnected: ensureConnectedMock, request: requestMock, disconnect: disconnectMock } }) // Make probeSocket return true: needs both the fs.existsSync proxy AND net.connect resolving "alive". probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { connect: [], error: [] } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) if (event === 'connect') { queueMicrotask(() => cb()) } return this }, removeListener(event: string, cb: () => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, destroy() {} } }) await mod.restartDaemon() // The shutdown RPC must have been issued with killSessions=true. expect(ensureConnectedMock).toHaveBeenCalled() expect(requestMock).toHaveBeenCalledWith('shutdown', { killSessions: true }) // The fallback killStaleDaemon must NOT fire when the RPC path worked. expect(killStaleDaemonMock).not.toHaveBeenCalled() }) it('cleans up daemon socket probe listeners when the probe times out', async () => { vi.useFakeTimers() try { const handlers: Record void>> = { connect: new Set(), error: new Set() } const socket = { on(event: string, cb: () => void) { handlers[event]?.add(cb) return this }, removeListener(event: string, cb: () => void) { handlers[event]?.delete(cb) return this }, destroy: vi.fn(), listenerCount(event: string) { return handlers[event]?.size ?? 0 } } probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockReturnValueOnce(socket) const mod = await importFresh() const cleanup = mod.cleanupDaemonForProtocol('/fake/daemon', PROTOCOL_VERSION) await Promise.resolve() expect(socket.listenerCount('connect')).toBe(1) expect(socket.listenerCount('error')).toBe(1) await vi.advanceTimersByTimeAsync(1000) await expect(cleanup).resolves.toEqual({ cleaned: false, killedCount: 0 }) expect(socket.destroy).toHaveBeenCalledTimes(1) expect(socket.listenerCount('connect')).toBe(0) expect(socket.listenerCount('error')).toBe(0) } finally { vi.useRealTimers() } }) it('falls back to killStaleDaemon without the live-PTY veto, so an explicit restart always wins', async () => { // Why: this is the documented escape hatch (Settings → Manage Sessions → Restart). Opting // into the veto here would report liveOwnerSurvived and throw, leaving the user no daemon. probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(() => { const handlers: Record void)[]> = { connect: [], error: [] } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) if (event === 'connect') { queueMicrotask(() => cb()) } return this }, removeListener(event: string, cb: () => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, destroy() {} } }) const mod = await importFresh() daemonClientMock.mockImplementationOnce(function MockWedgedDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('Hello response timed out') }), request: vi.fn(), disconnect: vi.fn() } }) await expect( mod.cleanupDaemonForProtocol('/fake/daemon', PROTOCOL_VERSION) ).resolves.toMatchObject({ cleaned: true }) expect(killStaleDaemonMock).toHaveBeenCalledWith( '/fake/daemon', `/fake/daemon/daemon-v${PROTOCOL_VERSION}.sock`, `/fake/daemon/daemon-v${PROTOCOL_VERSION}.token`, PROTOCOL_VERSION ) }) it('coalesces concurrent restartDaemon() calls so the 7-step sequence runs exactly once', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const originalSpawner = spawnerInstances[0] // Why: the deferred gate holds the first restart inside ensureRunning so the second call provably enters while the first is mid-flight. let markEnsureRunningEntered: (() => void) | undefined const ensureRunningEntered = new Promise((resolve) => { markEnsureRunningEntered = resolve }) let releaseEnsureRunning: (() => void) | undefined const ensureRunningBarrier = new Promise((resolve) => { releaseEnsureRunning = resolve }) originalSpawner.ensureRunning.mockImplementationOnce(async () => { markEnsureRunningEntered?.() await ensureRunningBarrier return { socketPath: '/fake/socket-2', tokenPath: '/fake/token-2' } }) const call1 = mod.restartDaemon() await ensureRunningEntered const call2 = mod.restartDaemon() // Why: restartDaemon wraps each return in a fresh Promise, so call1===call2 can't prove coalescing; instead assert resetHandle stayed at 1 mid-flight. expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1) expect(adapterInstances).toHaveLength(1) releaseEnsureRunning?.() const [r1, r2] = await Promise.all([call1, call2]) // Both resolved values are structurally identical — same result bubbled up through the shared runRestartDaemon promise. expect(r1).toEqual(r2) // resetHandle=1/restart, ensureRunning=1 init+1 restart; an un-coalesced second restart would push these to 2 and 3. expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1) expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2) expect(adapterInstances).toHaveLength(2) // After the in-flight promise settles, a fresh restart runs — proves .finally cleared restartInFlight (a stale slot would skip work). await mod.restartDaemon() expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(2) expect(adapterInstances).toHaveLength(3) }) it('throws when restartDaemon is called before initDaemonPtyProvider', async () => { const mod = await importFresh() await expect(mod.restartDaemon()).rejects.toThrow( 'restartDaemon called before initDaemonPtyProvider' ) }) it('respawns instead of reusing a healthy daemon launched from another app path', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') forkMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { message: [], error: [], exit: [] } return { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off(event: string, cb: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, disconnect: vi.fn(), unref: vi.fn() } }) await launcher('/fake/socket', '/fake/token') expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', FAKE_DAEMON_ENTRY_PATH ) expect(killStaleDaemonMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, expect.arrayContaining([ '--socket', '/fake/socket', '--token', '/fake/token', '--login-session-watch', '--log-file', join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') ]), expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) // STA-2376: different-app-path replacement, emitted exactly once. expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('different_app_path', 0) }) it('adopts the winner when a launched daemon loses the endpoint race', async () => { // Why: losing the publish race is an expected outcome, not a crash. Reporting it as a // startup failure strands this app on local non-persistent PTYs beside a healthy daemon. const mod = await importFresh() await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> // Force the replace-and-launch path; otherwise the launcher adopts the healthy daemon and // never forks, and this test would pass without exercising anything. getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') forkMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { message: [], error: [], exit: [] } return { pid: 24680, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) // Why the exit code and not the message: the launcher settles on exit, so keying // adoption off the notification alone could lose that race. if (event === 'exit') { queueMicrotask(() => cb(20)) } return this }, off(event: string, cb: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, kill: vi.fn(), disconnect: vi.fn(), unref: vi.fn() } }) // A handle rather than a rejection means the incumbent was adopted. await expect(launcher('/fake/socket', '/fake/token')).resolves.toMatchObject({ shutdown: expect.any(Function) }) // Guard against passing for the wrong reason: the adoption must follow a real launch. expect(forkMock).toHaveBeenCalledTimes(1) }) it('replaces a healthy daemon whose macOS TCC attribution is severed when it has no live sessions', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') forkMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { message: [], error: [], exit: [] } return { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off(event: string, cb: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, disconnect: vi.fn(), unref: vi.fn() } }) await launcher('/fake/socket', '/fake/token') expect(forkMock).toHaveBeenCalledTimes(1) // STA-3491: attribution-severed replacement is billed to its own reason, exactly once. expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('severed_tcc_attribution', 0) }) it('preserves a severed-attribution daemon that owns live sessions', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') // Why: live sessions must veto replacement — the Settings surface owns the remedy instead. daemonClientMock.mockImplementation(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [{ sessionId: 's1', isAlive: true }] })), disconnect: vi.fn() } }) const handle = await launcher('/fake/socket', '/fake/token') expect(handle).toBeDefined() expect(forkMock).not.toHaveBeenCalled() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(trackDaemonReplacedMock).not.toHaveBeenCalled() }) it('holds a full adoption pair before a healthy launcher resolves', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const events: string[] = [] const disconnect = vi.fn() daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { return { ensureConnected: vi.fn(async () => { events.push('full-pair') }), request: vi.fn(), disconnect } }) checkDaemonHealthMock.mockImplementationOnce(async () => { events.push('health') return 'healthy' }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ releaseAdoptionLease?(): void shutdown(): Promise }> const handle = await launcher('/fake/socket', '/fake/token') expect(events[0]).toBe('full-pair') expect(events.indexOf('full-pair')).toBeLessThan(events.indexOf('health')) expect(disconnect).not.toHaveBeenCalled() handle.releaseAdoptionLease?.() expect(disconnect).toHaveBeenCalledOnce() }) it('repairs a stale PID record to the authenticated socket owner before adoption', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const endpointIdentity = { pid: 101, startedAtMs: 1_000_000, launchNonce: 'socket-owner' } daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { return { ensureConnected: vi.fn(async () => {}), getDaemonIdentity: vi.fn(() => endpointIdentity), request: vi.fn(), disconnect: vi.fn() } }) readFileSyncMock.mockReturnValueOnce( JSON.stringify({ pid: 202, startedAtMs: 2_000_000, launchNonce: 'stale-owner' }) ) readFileSyncMock.mockReturnValueOnce(JSON.stringify(endpointIdentity)) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string, pidPath?: string, launchNonce?: string ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const handle = await launcher( '/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused-new-launch' ) expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', endpointIdentity) expect(warn).toHaveBeenCalledWith( '[daemon] Repaired daemon PID ownership to match the authenticated endpoint' ) handle.releaseAdoptionLease?.() } finally { warn.mockRestore() } }) it('republishes the endpoint owner launch metadata into a repaired PID record', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() // Why: the mismatched record's metadata belongs to another daemon, so freshness and // host-pinning fields must come from the authenticated owner — a record without // appVersion reads as a permanently stale bundle and gets needlessly replaced. The // install path deliberately contains a space: re-parsing it out of a space-joined // command line truncated it and made a healthy daemon look like a different app path. const endpointIdentity = { pid: 101, startedAtMs: 1_000_000, launchNonce: 'socket-owner', entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js', appVersion: '9.9.9', spawnerExecPath: '/Applications/Orca 2.app/Contents/MacOS/Orca' } daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { return { ensureConnected: vi.fn(async () => {}), getDaemonIdentity: vi.fn(() => endpointIdentity), request: vi.fn(), disconnect: vi.fn() } }) readFileSyncMock.mockReturnValueOnce( JSON.stringify({ pid: 202, startedAtMs: 2_000_000, launchNonce: 'stale-owner' }) ) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string, pidPath?: string, launchNonce?: string ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused') expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', { pid: 101, startedAtMs: 1_000_000, launchNonce: 'socket-owner', entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js', appVersion: '9.9.9', spawnerExecPath: '/Applications/Orca 2.app/Contents/MacOS/Orca' }) handle.releaseAdoptionLease?.() } finally { warn.mockRestore() } }) it('adopts the authenticated endpoint even when the PID record cannot be repaired', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const endpointIdentity = { pid: 101, startedAtMs: 1_000_000, launchNonce: 'socket-owner' } daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { return { ensureConnected: vi.fn(async () => {}), getDaemonIdentity: vi.fn(() => endpointIdentity), request: vi.fn(), disconnect: vi.fn() } }) readFileSyncMock.mockReturnValueOnce( JSON.stringify({ pid: 202, startedAtMs: 2_000_000, launchNonce: 'stale-owner' }) ) // Why: fail open. Losing every persistent terminal because a pid file write failed is a // far worse outcome than a record that disagrees with the endpoint. replaceDaemonPidFileMock.mockReturnValueOnce(false) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string, pidPath?: string, launchNonce?: string ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const forkCallsBefore = forkMock.mock.calls.length try { const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused') expect(handle).toBeDefined() // The healthy daemon is adopted, not replaced. expect(forkMock.mock.calls.length).toBe(forkCallsBefore) expect(warn).toHaveBeenCalledWith( '[daemon] Could not repair daemon PID ownership; adopting the authenticated endpoint anyway' ) handle.releaseAdoptionLease?.() } finally { warn.mockRestore() } }) it('degrades instead of forking beside a daemon that could not be confirmed stopped', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const forkCallsBefore = forkMock.mock.calls.length // Why: forking beside a survivor is exactly how the endpoint owner and the session host // diverge — one daemon answers the socket while another hosts the visible terminals. But // refusing outright would leave the user with no daemon at all, and something demonstrably // still answers the endpoint, so adopt it degraded: live sessions keep working. killStaleDaemonMock.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string, pidPath?: string, launchNonce?: string ) => Promise<{ mode?: string; releaseAdoptionLease?(): void }> const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new') expect(handle.mode).toBe('degraded-new-pty-fallback') expect(forkMock.mock.calls.length).toBe(forkCallsBefore) handle.releaseAdoptionLease?.() } finally { warn.mockRestore() } }) it('disconnects every temporary client when healthy adoption fails', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const initialDisconnect = vi.fn() const replacementDisconnect = vi.fn() daemonClientMock .mockImplementationOnce(function MockInitialAdoptionClient() { return { ensureConnected: vi.fn(async () => { throw new Error('initial adoption failed') }), request: vi.fn(), disconnect: initialDisconnect } }) .mockImplementationOnce(function MockReplacementAdoptionClient() { return { ensureConnected: vi.fn(async () => { throw new Error('replacement adoption failed') }), request: vi.fn(), disconnect: replacementDisconnect } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'replacement adoption failed' ) expect(initialDisconnect).toHaveBeenCalledOnce() expect(replacementDisconnect).toHaveBeenCalledOnce() expect(forkMock).not.toHaveBeenCalled() }) 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() mockConnectedAdoptionClientOnce() 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_RUNTIME_DIR, '/fake/socket', '/fake/token', FAKE_DAEMON_ENTRY_PATH ) expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) 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() mockConnectedAdoptionClientOnce() 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.any(Number)) 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() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') forkMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { message: [], error: [], exit: [] } return { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off(event: string, cb: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, disconnect: vi.fn(), unref: vi.fn() } }) await launcher('/fake/socket', '/fake/token') expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token') expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() expect(killStaleDaemonMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, expect.arrayContaining([ '--socket', '/fake/socket', '--token', '/fake/token', '--log-file', join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') ]), expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) // STA-2376: the launcher is the sole emitter for a resolver replace, and fires exactly once. expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('unhealthy_resolver', 0) }) it('preserves a resolver-unhealthy daemon 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() mockConnectedAdoptionClientOnce() 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 }> getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') await launcher('/fake/socket', '/fake/token') expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token') expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() // STA-2376: preserving a daemon is not a lifecycle transition — no event. expect(trackDaemonReplacedMock).not.toHaveBeenCalled() }) it('preserves a resolver-unhealthy daemon 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() mockConnectedAdoptionClientOnce() 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 }> getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') await launcher('/fake/socket', '/fake/token') expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) it('uses the direct daemon entry when Electron app path is already out/main', async () => { probeSocketExistsMock.mockImplementation((p?: string) => p === FAKE_DAEMON_ENTRY_PATH) const mod = await importFresh() getAppPathMock.mockReturnValue(FAKE_APP_OUT_MAIN_PATH) checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> forkMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { message: [], error: [], exit: [] } return { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off(event: string, cb: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, disconnect: vi.fn(), unref: vi.fn() } }) await launcher('/fake/socket', '/fake/token') expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, expect.arrayContaining([ '--socket', '/fake/socket', '--token', '/fake/token', '--log-file', join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') ]), expect.objectContaining({ detached: true }) ) // STA-2376: an unreachable daemon with no live sessions is replaced via the failed-health path, once. expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('failed_health_check', 0) }) it('does not report a replacement when startup finds no daemon to remove', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() checkDaemonHealthMock.mockResolvedValue('unreachable') killStaleDaemonMock.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'stop after replacement decision' ) expect(trackDaemonReplacedMock).not.toHaveBeenCalled() }) // STA-2376 regression: dropping the adapter's last authenticated client is enough to make an idle // daemon self-retire, so by the time the launcher runs there is nothing to kill and its own // confirmed-kill gate reports nothing. The attributed reason is what keeps the runtime resolver // replacement on the wire — and keeps it off the failed_health_check bucket it would otherwise land in. it('reports the runtime resolver replacement even after the daemon self-retired', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const adapterOptions = adapterInstances[0].options trackDaemonReplacedMock.mockClear() // The daemon is gone before the launcher looks: nothing answers, nothing left to kill. checkDaemonHealthMock.mockResolvedValue('unreachable') killStaleDaemonMock .mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) .mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> await adapterOptions.respawn?.('unhealthy_resolver') expect(trackDaemonReplacedMock).not.toHaveBeenCalled() await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'stop after replacement decision' ) expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('unhealthy_resolver', 0) // One-shot: a later unrelated launch must not inherit the attribution. trackDaemonReplacedMock.mockClear() forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'stop after replacement decision' ) expect(trackDaemonReplacedMock).not.toHaveBeenCalled() }) // STA-2376: the attribution covers the case the confirmed-kill gate cannot see; it must not // overwrite a reason this launch proved against the daemon it actually removed. Otherwise a // resolver that recovers mid-flight bills a real stale-bundle replacement to the resolver bucket. it('prefers a proven replacement reason over the attributed one', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const adapterOptions = adapterInstances[0].options trackDaemonReplacedMock.mockClear() // Resolver recovered by the time the launcher looks, but the daemon is genuinely from another path. getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy') getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> await adapterOptions.respawn?.('unhealthy_resolver') await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'stop after replacement decision' ) expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('different_app_path', 0) }) // STA-2376: failed_health_check is the residual bucket, not an identification, so it must not // absorb the attribution. The same dead login session that fails the resolver also fails the PTY // spawn probe, and with zero live sessions that lands here instead of the degraded preserve — // so this is the likely shape of the incident, not a corner case. it('keeps the attributed reason when the launcher only reaches failed_health_check', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const adapterOptions = adapterInstances[0].options trackDaemonReplacedMock.mockClear() // Daemon survived the disconnect (non-alive sessions keep it non-idle) but fails the spawn probe. checkDaemonHealthMock.mockResolvedValue('pty-spawn-unhealthy') forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> await adapterOptions.respawn?.('unhealthy_resolver') await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'stop after replacement decision' ) expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('unhealthy_resolver', 0) }) // STA-2376: in the field the identified reasons confirm via cleanupDaemonForProtocol().cleaned, not // via killStaleDaemon — the daemon is healthy, so cleanup shuts it down over RPC and unlinks its pid, // leaving nothing for the kill to find. The other tests reach confirmedReplacement through the kill, // so without this one the `.cleaned` half could be dropped and every identified reason would go // silent in production with the suite still green. it('reports a replacement confirmed by cleanup alone, with no stale daemon left to kill', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() trackDaemonReplacedMock.mockClear() getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') killStaleDaemonMock.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) // The daemon answers cleanup's liveness probe, then the endpoint goes away so the self-shutdown // wait succeeds and cleanup reports cleaned:true. probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { connect: [], error: [] } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) if (event === 'connect') { queueMicrotask(() => cb()) } return this }, removeListener(event: string, cb: () => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, destroy() {} } }) forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'stop after replacement decision' ) expect(killStaleDaemonMock).toHaveBeenCalled() expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('different_app_path', 0) // beforeEach only mockClear()s this one, so hand it back rather than leaving later tests probing a live endpoint. probeSocketExistsMock.mockReturnValue(false) }) it('removes detached daemon startup listeners after readiness', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> const handlers: Record void)[]> = { message: [], error: [], exit: [] } const offMock = vi.fn((event: string, cb: (arg?: unknown) => void) => { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return child }) const child = { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000, linuxStartTicks: '4242', bootId: 'boot-a' }) ) } return this }, off: offMock, disconnect: vi.fn(), unref: vi.fn() } forkMock.mockReturnValueOnce(child) await launcher('/fake/socket', '/fake/token') const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[] expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch') expect(offMock).toHaveBeenCalledWith('message', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('error', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function)) expect(handlers.message).toHaveLength(0) expect(handlers.error).toHaveLength(0) expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() expect(writeFileSyncMock).not.toHaveBeenCalled() const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[] const launchNonceIndex = launchArgs.indexOf('--launch-nonce') expect(launchArgs).toEqual( expect.arrayContaining([ '--pid-record', `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, '--launch-nonce', expect.stringMatching(/^[0-9a-f-]{36}$/), '--entry-path', FAKE_DAEMON_ENTRY_PATH, '--app-version', '1.2.3', '--spawner-exec-path', process.execPath ]) ) expect(launchArgs[launchNonceIndex + 1]).toMatch(/^[0-9a-f-]{36}$/) }) it.each([ [ 'changed endpoint identity', () => ({ getDaemonIdentity: vi.fn(() => ({ pid: 999, startedAtMs: 900_000, launchNonce: 'stale-launch' })) }) ], ['missing endpoint identity reader', () => ({})] ])('rejects and reaps a child with %s before adoption', async (_case, identityMethods) => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() function basicClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } daemonClientMock.mockImplementationOnce(basicClient) daemonClientMock.mockImplementationOnce(basicClient) daemonClientMock.mockImplementationOnce(function postReadyClient() { return { ...basicClient(), ...identityMethods() } }) const handlers: Record void)[]> = { message: [], error: [], exit: [] } const child = { pid: 12345, connected: true, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null, on(event: string, callback: (arg?: unknown) => void) { handlers[event]?.push(callback) if (event === 'message') { queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off(event: string, callback: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] return this }, kill: vi.fn(() => true), disconnect: vi.fn(() => { child.connected = false }), unref: vi.fn() } const kill = vi.spyOn(process, 'kill').mockImplementation(() => { queueMicrotask(() => { child.exitCode = 0 for (const callback of handlers.exit.slice()) { callback(0) } }) return true }) forkMock.mockReturnValueOnce(child) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string, pidPath?: string, launchNonce?: string ) => Promise<{ shutdown(): Promise }> try { await expect( launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new') ).rejects.toThrow('Daemon endpoint ownership changed during startup') expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( '/fake/daemon.pid', 12345, 'launch-new' ) } finally { kill.mockRestore() } }) it('keeps a live PID record after adoption failure and removes it on exact child exit', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const adoptionDisconnects: ReturnType[] = [] function MockFailingAdoptionClient() { const disconnect = vi.fn() adoptionDisconnects.push(disconnect) return { ensureConnected: vi.fn(async () => { throw new Error('adoption unavailable') }), request: vi.fn(), disconnect } } for (let index = 0; index < 3; index++) { daemonClientMock.mockImplementationOnce(MockFailingAdoptionClient) } const handlers: Record void)[]> = { message: [], error: [], exit: [] } const child = { pid: 12345, connected: true, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null, on(event: string, callback: (arg?: unknown) => void) { handlers[event]?.push(callback) if (event === 'message') { queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, once(event: string, callback: (arg?: unknown) => void) { handlers[event]?.push(callback) return this }, off(event: string, callback: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] return this }, disconnect: vi.fn(() => { child.connected = false }), unref: vi.fn() } forkMock.mockReturnValueOnce(child) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string, pidPath?: string, launchNonce?: string ) => Promise<{ shutdown(): Promise }> await expect( launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-delayed') ).rejects.toThrow('adoption unavailable') expect(writeFileSyncMock).not.toHaveBeenCalled() expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled() expect(adoptionDisconnects.at(-1)).toHaveBeenCalledOnce() child.exitCode = 0 for (const callback of handlers.exit.slice()) { callback(0) } expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( '/fake/daemon.pid', 12345, 'launch-delayed' ) }) it('kills and rejects a daemon whose readiness message omits its start time', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> const kill = vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) }) const child = { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { queueMicrotask(() => cb({ type: 'ready' })) } return this }, off: vi.fn(), disconnect: vi.fn(), unref: vi.fn() } forkMock.mockReturnValueOnce(child) try { await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'Daemon readiness identity is incomplete' ) expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') expect(writeFileSyncMock).not.toHaveBeenCalled() expect(child.disconnect).not.toHaveBeenCalled() expect(child.unref).toHaveBeenCalledOnce() } finally { kill.mockRestore() } }) it('rejects startup cleanup when SIGKILL never produces child exit', async () => { vi.useFakeTimers() const kill = vi.spyOn(process, 'kill').mockReturnValue(true) try { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const handlers: Record void)[]> = { message: [], error: [], exit: [] } const child = { pid: 12345, connected: true, exitCode: null, signalCode: null, on(event: string, callback: (arg?: unknown) => void) { handlers[event]?.push(callback) if (event === 'message') { queueMicrotask(() => callback({ type: 'ready' })) } return this }, off(event: string, callback: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] return this }, disconnect: vi.fn(() => { child.connected = false }), unref: vi.fn() } forkMock.mockReturnValueOnce(child) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> const launch = launcher('/fake/socket', '/fake/token') await Promise.resolve() await Promise.resolve() const rejection = expect(launch).rejects.toThrow('startup and child cleanup both failed') await vi.advanceTimersByTimeAsync(6_000) await rejection expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM') expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled() } finally { kill.mockRestore() vi.useRealTimers() } }) it('surfaces non-ESRCH startup termination errors and releases IPC', async () => { const signalError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) const kill = vi.spyOn(process, 'kill').mockImplementation(() => { throw signalError }) try { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const child = { pid: 12345, connected: true, exitCode: null, signalCode: null, on(event: string, callback: (arg?: unknown) => void) { if (event === 'message') { queueMicrotask(() => callback({ type: 'ready' })) } return this }, off: vi.fn(), disconnect: vi.fn(() => { child.connected = false }), unref: vi.fn() } forkMock.mockReturnValueOnce(child) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> const error = await launcher('/fake/socket', '/fake/token').catch((caught: unknown) => caught) expect(error).toBeInstanceOf(AggregateError) expect((error as AggregateError).errors).toEqual([ expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), signalError ]) expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() } finally { kill.mockRestore() } }) it('settles startup with both errors when a malformed-ready child ignores termination', async () => { vi.useFakeTimers() const kill = vi.spyOn(process, 'kill').mockReturnValue(true) try { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const handlers: Record void)[]> = { message: [], error: [], exit: [] } const child = { pid: 12345, connected: true, exitCode: null, signalCode: null, on(event: string, callback: (arg?: unknown) => void) { handlers[event]?.push(callback) if (event === 'message') { queueMicrotask(() => callback({ type: 'ready' })) } return this }, off(event: string, callback: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] return this }, disconnect: vi.fn(() => { child.connected = false }), unref: vi.fn() } forkMock.mockReturnValueOnce(child) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> const launch = launcher('/fake/socket', '/fake/token').catch((error: unknown) => error) await vi.advanceTimersByTimeAsync(6_000) const error = await launch expect(error).toBeInstanceOf(AggregateError) expect((error as AggregateError).errors).toEqual([ expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), expect.objectContaining({ message: 'Daemon did not exit after SIGKILL' }) ]) expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM') expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') expect(handlers.message).toHaveLength(0) expect(handlers.error).toHaveLength(0) expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() } finally { kill.mockRestore() vi.useRealTimers() } }) it('leaves exclusive PID publication to the daemon child', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> const child = { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off: vi.fn(), disconnect: vi.fn(), unref: vi.fn() } forkMock.mockReturnValueOnce(child) await launcher('/fake/socket', '/fake/token') expect(writeFileSyncMock).not.toHaveBeenCalled() expect(forkMock.mock.calls.at(-1)?.[1]).toEqual( expect.arrayContaining([ '--pid-record', `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, '--entry-path', FAKE_DAEMON_ENTRY_PATH, '--app-version', '1.2.3', '--spawner-exec-path', process.execPath ]) ) }) it('removes detached daemon startup listeners after startup error', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> const handlers: Record void)[]> = { message: [], error: [], exit: [] } const offMock = vi.fn((event: string, cb: (arg?: unknown) => void) => { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return child }) const child = { pid: undefined, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'error') { queueMicrotask(() => cb(new Error('startup failed'))) } return this }, off: offMock, disconnect: vi.fn(), unref: vi.fn() } forkMock.mockReturnValueOnce(child) await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow('startup failed') expect(offMock).toHaveBeenCalledWith('message', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('error', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function)) expect(handlers.message).toHaveLength(0) expect(handlers.error).toHaveLength(0) expect(handlers.exit).toHaveLength(0) expect(child.disconnect).not.toHaveBeenCalled() expect(child.unref).toHaveBeenCalledOnce() }) it('captures daemon startup stderr into the failure error', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string, pidPath?: string, launchNonce?: string ) => Promise<{ shutdown(): Promise }> const handlers: Record void)[]> = { message: [], error: [], exit: [] } const stderrDataCbs: ((chunk: Buffer) => void)[] = [] const stderrDestroy = vi.fn() const stderr = { on(event: string, cb: (chunk: Buffer) => void) { if (event === 'data') { stderrDataCbs.push(cb) } return this }, off(event: string, cb: (chunk: Buffer) => void) { if (event === 'data') { const idx = stderrDataCbs.indexOf(cb) if (idx !== -1) { stderrDataCbs.splice(idx, 1) } } return this }, destroy: stderrDestroy } const child = { pid: 4321, exitCode: null as number | null, stderr, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'exit') { // Why: deliver the stderr tail before exit so the failure path sees the crash reason (mirrors a module-load crash). queueMicrotask(() => { for (const dataCb of stderrDataCbs.slice()) { dataCb(Buffer.from("Error: Cannot find module 'electron'\n")) } child.exitCode = 1 cb(1) }) } return this }, off: vi.fn((event: string, cb: (arg?: unknown) => void) => { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return child }), disconnect: vi.fn(), unref: vi.fn() } forkMock.mockReturnValueOnce(child) const error = await launcher( '/fake/socket', '/fake/token', '/fake/daemon.pid', 'failed-launch' ).catch((err: Error) => err) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toMatch(/Cannot find module 'electron'/) expect((error as Error).message).toMatch(/Daemon stderr \(tail\)/) expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( '/fake/daemon.pid', 4321, 'failed-launch' ) // Why: release the piped stderr so the detached daemon can't keep the parent event loop alive after failure. expect(stderrDestroy).toHaveBeenCalled() }) it('destroys the daemon stderr pipe once the daemon signals ready', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> const handlers: Record void)[]> = { message: [], error: [], exit: [] } const stderrOff = vi.fn() const stderrDestroy = vi.fn() const stderr = { on() { return this }, off: stderrOff, destroy: stderrDestroy } const child = { pid: 12345, stderr, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, once(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) return this }, off: vi.fn(() => child), disconnect: vi.fn(), unref: vi.fn() } forkMock.mockReturnValueOnce(child) await launcher('/fake/socket', '/fake/token') expect(stderrOff).toHaveBeenCalledWith('data', expect.any(Function)) expect(stderrDestroy).toHaveBeenCalledOnce() expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() }) it('preserves a health-check-failing daemon 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() mockConnectedAdoptionClientOnce() 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 }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') await launcher('/fake/socket', '/fake/token') expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) it('marks a preserved daemon as degraded when its PTY spawn health check fails', 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 {} }) mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: requestMock, disconnect: vi.fn() } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ mode?: 'degraded-new-pty-fallback' shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') const handle = await launcher('/fake/socket', '/fake/token') expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(handle.mode).toBe('degraded-new-pty-fallback') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) // Note: only the adoption client is wedged here; the session probe answers, so this is the // verified-zero case and the live-PTY veto stays off. The unverifiable path is covered by // the grace-retry tests below. it('replaces a health-check-failing daemon that reports no sessions when the pipe is dead', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('daemon is wedged') }), request: vi.fn(), disconnect: vi.fn() } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') forkMock.mockImplementationOnce(() => ({ pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, once() { return this }, off() { return this }, disconnect: vi.fn(), unref: vi.fn() })) await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalled() }) it('stays silent about replacing a daemon on a cold start, where there is none', async () => { // Why: a first launch reaches the same replace fall-through (unreachable health, // no socket, nothing to probe); announcing a replacement there reports killing a // daemon that never existed, on the most common path there is. const mod = await importFresh() await mod.initDaemonPtyProvider() // Both pre-spawn probes fail: nothing ever answers, so no session count is observed. const unreachableClient = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('connect ENOENT') }), request: vi.fn(), disconnect: vi.fn() } } daemonClientMock .mockImplementationOnce(unreachableClient) .mockImplementationOnce(unreachableClient) // A cold start has no pid record to act on, so nothing is ever killed. killStaleDaemonMock.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') probeSocketExistsMock.mockReturnValue(false) forkMock.mockImplementationOnce(() => ({ pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, once() { return this }, off() { return this }, disconnect: vi.fn(), unref: vi.fn() })) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { await launcher('/fake/socket', '/fake/token') expect(forkMock).toHaveBeenCalled() expect(warnSpy).not.toHaveBeenCalledWith( expect.stringContaining('Replacing daemon that failed the health check') ) } finally { warnSpy.mockRestore() } }) // Why: net.connect stub whose 'connect' fires, so probeSocket() reports the pipe alive on every grace re-check. function stubAliveSocketConnect() { const handlers: Record void)[]> = { connect: [], error: [] } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) if (event === 'connect') { queueMicrotask(() => cb()) } return this }, removeListener(event: string, cb: () => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, destroy() {} } } it('adopts a transiently wedged daemon that drains and reports live sessions within the grace window', async () => { // Why: Windows update-relaunch — post-install load wedges the daemon briefly; it still owns live sessions, so grace-adopt not kill. const mod = await importFresh() await mod.initDaemonPtyProvider() // First probe times out (still draining); the retry within grace succeeds with a live session. daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('Hello response timed out') }), request: vi.fn(), disconnect: vi.fn() } }) daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] })), disconnect: vi.fn() } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) it('replaces a permanently wedged daemon after the grace window is exhausted (#8689)', async () => { // Why: a socket that accepts connections but never answers hello was preserved forever (#8689); after grace it must be replaced. const mod = await importFresh() await mod.initDaemonPtyProvider() const answeringDefault = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } // Permanent wedge: every probe times out, then the freshly spawned daemon accepts the temporary adoption lease. let daemonClientConstructionCount = 0 daemonClientMock.mockImplementation(function MockDaemonClient() { daemonClientConstructionCount++ return { ensureConnected: vi.fn(async () => { if (daemonClientConstructionCount <= 2 + WEDGED_DAEMON_GRACE_RETRIES) { throw new Error('Hello response timed out') } }), getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(), disconnect: vi.fn() } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) forkMock.mockImplementationOnce(() => ({ pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off() { return this }, disconnect: vi.fn(), unref: vi.fn() })) // Count only the launcher's own session-count probes. daemonClientMock.mockClear() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalled() // 1 adoption + 1 patient probe + the cheap retries + 1 post-fork adoption. The patient // probe comes first and is the point: retrying a four-second handshake never reaches a // daemon that consistently needs longer, and spending the clock on the cheap asks first // left the tolerant one unable to fund an answer. expect(daemonClientMock).toHaveBeenCalledTimes(3 + WEDGED_DAEMON_GRACE_RETRIES) // Why: this replace path used to kill the daemon with no log, so a post-hoc // reader could not tell it apart from an adoption; the verdict must be recorded. expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Replacing daemon that failed the health check') ) expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining(`graceRetries=${WEDGED_DAEMON_GRACE_RETRIES}`) ) } finally { warnSpy.mockRestore() // Restore the answering default: clearAllMocks clears calls not impls, so the throwing impl would leak into later tests. daemonClientMock.mockImplementation(answeringDefault) } }) it('holds a wedged daemon that still owns live terminals instead of killing its agents', async () => { // Why hold and not adopt: a daemon that cannot answer listSessions cannot answer a hello, // so there is nothing to adopt. The kill must never be attempted — its agents are alive. const mod = await importFresh() await mod.initDaemonPtyProvider() const answeringDefault = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } daemonClientMock.mockImplementation(function MockWedgedDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('Hello response timed out') }), getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(), disconnect: vi.fn() } }) // The daemon is identity-verified and its process still owns live terminals. readVerifiedDaemonPidMock.mockResolvedValue({ pid: 4242 }) inspectDaemonPtyOwnershipMock.mockResolvedValue('owns-live-ptys') const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise; mode?: string }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) try { const handle = await launcher('/fake/socket', '/fake/token') expect(handle.mode).toBe('held') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() } finally { daemonClientMock.mockImplementation(answeringDefault) readVerifiedDaemonPidMock.mockResolvedValue(null) inspectDaemonPtyOwnershipMock.mockResolvedValue('unknown') } }) it('adopts a PTY-spawn-unhealthy daemon in degraded mode when it can still answer', async () => { // Why the count must come from IPC: an answered listSessions is what proves the daemon // can still complete a handshake, and adoption opens one. const mod = await importFresh() await mod.initDaemonPtyProvider() const answeringDefault = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } daemonClientMock.mockImplementation(function MockAnsweringDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [{ isAlive: true }] })), getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), disconnect: vi.fn() } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise; mode?: string }> checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) try { const handle = await launcher('/fake/socket', '/fake/token') expect(handle.mode).toBe('degraded-new-pty-fallback') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() } finally { daemonClientMock.mockImplementation(answeringDefault) } }) it('holds a PTY-spawn-unhealthy daemon that has since stopped answering', async () => { // Why: `health` is a reading from before the grace window. If listSessions went // unanswered across all of it, adoption would open a hello the daemon can no longer // complete — and that throw costs the app its daemon. const mod = await importFresh() await mod.initDaemonPtyProvider() const answeringDefault = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } daemonClientMock.mockImplementation(function MockSilentDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => { throw new Error('listSessions timed out') }), getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), disconnect: vi.fn() } }) readVerifiedDaemonPidMock.mockResolvedValue({ pid: 4242 }) inspectDaemonPtyOwnershipMock.mockResolvedValue('owns-live-ptys') const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise; mode?: string }> checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) try { const handle = await launcher('/fake/socket', '/fake/token') expect(handle.mode).toBe('held') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() } finally { daemonClientMock.mockImplementation(answeringDefault) readVerifiedDaemonPidMock.mockResolvedValue(null) inspectDaemonPtyOwnershipMock.mockResolvedValue('unknown') } }) it('keeps a daemon handle when the preserved daemon is too wedged to be adopted', async () => { // Why: adoption needs a hello, which is exactly what a daemon wedged enough to be // preserved cannot answer. Throwing here would abort initDaemonPtyProvider, leaving no // spawner — and restartDaemon() throws without one, so the user loses the documented // Manage Sessions → Restart remedy on top of having no daemon. const mod = await importFresh() await mod.initDaemonPtyProvider() const answeringDefault = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } // Permanently wedged: every client, including the adoption client, fails its hello. daemonClientMock.mockImplementation(function MockWedgedDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('Hello response timed out') }), getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(), disconnect: vi.fn() } }) killStaleDaemonMock.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise; mode?: string }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') // The endpoint still listens, so the daemon is wedged rather than gone. probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) try { const handle = await launcher('/fake/socket', '/fake/token') // 'held', not merely degraded: init must not attempt a lease on a daemon whose // adoption hello just failed — that throw would cost the app its daemon entirely. expect(handle.mode).toBe('held') expect(forkMock).not.toHaveBeenCalled() } finally { daemonClientMock.mockImplementation(answeringDefault) } }) it('holds a hello-rejected daemon that owns live terminals rather than adopting it', async () => { // Why: 'rejected' means it answered and refused the handshake, so adoption can never // succeed. Falling through to preserveDaemon() would throw and cost the app its daemon // entirely — and killing it would end agents that are still running. const mod = await importFresh() await mod.initDaemonPtyProvider() const answeringDefault = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } daemonClientMock.mockImplementation(function MockRejectingDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('hello refused') }), getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(), disconnect: vi.fn() } }) readVerifiedDaemonPidMock.mockResolvedValue({ pid: 4242 }) inspectDaemonPtyOwnershipMock.mockResolvedValue('owns-live-ptys') const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise; mode?: string }> checkDaemonHealthMock.mockResolvedValueOnce('rejected') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) try { const handle = await launcher('/fake/socket', '/fake/token') expect(handle.mode).toBe('held') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() } finally { daemonClientMock.mockImplementation(answeringDefault) readVerifiedDaemonPidMock.mockResolvedValue(null) inspectDaemonPtyOwnershipMock.mockResolvedValue('unknown') } }) it('stops grace-retrying when the classification clock runs out, with retries still left', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const answeringDefault = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } // Permanently wedged: without the budget this drains all WEDGED_DAEMON_GRACE_RETRIES probes. daemonClientMock.mockImplementation(function MockWedgedDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('Hello response timed out') }), request: vi.fn(), disconnect: vi.fn() } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) // Stand in for the hello timeout each probe really waits out: mocked probes return instantly, // so only a moving clock can exercise the budget without a slow (or flaky) real wait. const startMs = Date.now() let elapsedMs = 0 const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { const value = startMs + elapsedMs elapsedMs += WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS / 3 return value }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( 'stop after replacement decision' ) const verdict = warnSpy.mock.calls .map((call) => String(call[0])) .find((message) => message.includes('graceRetries=')) expect(verdict).toBeDefined() const graceRetries = Number(/graceRetries=(\d+)/.exec(verdict as string)?.[1]) expect(graceRetries).toBeLessThan(WEDGED_DAEMON_GRACE_RETRIES) } finally { nowSpy.mockRestore() warnSpy.mockRestore() daemonClientMock.mockImplementation(answeringDefault) } }) it('preserves a daemon that drains on the last probe the budget allows', async () => { // Why the clock is frozen: the probes here are instantaneous, so without pinning time the // wall-clock ceiling would never bind and this would assert a retry depth the real code // cannot reach. Freezing it lets the loop run to its retry ceiling deliberately, which is // the thing under test — that a daemon draining on the final allowed probe is preserved. const frozenNow = Date.now() vi.spyOn(Date, 'now').mockReturnValue(frozenNow) const mod = await importFresh() await mod.initDaemonPtyProvider() let probe = 0 const answeringDefault = function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } } daemonClientMock.mockImplementation(function MockDaemonClient() { probe += 1 const drainsNow = probe >= 1 + WEDGED_DAEMON_GRACE_RETRIES return { ensureConnected: vi.fn(async () => { if (!drainsNow) { throw new Error('Hello response timed out') } }), request: vi.fn(async () => ({ sessions: drainsNow ? [{ sessionId: 'wt-1@@live', isAlive: true }] : [] })), disconnect: vi.fn() } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) try { await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() } finally { daemonClientMock.mockImplementation(answeringDefault) } }) it('replaces a hello-rejected daemon even though its pipe accepts connections', async () => { // Why: 'rejected' = daemon refused the handshake; it can never be adopted, so replacement is the only recovery. const mod = await importFresh() await mod.initDaemonPtyProvider() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => { throw new Error('Hello rejected') }), request: vi.fn(), disconnect: vi.fn() } }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('rejected') probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementation(stubAliveSocketConnect) forkMock.mockImplementationOnce(() => ({ pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off() { return this }, disconnect: vi.fn(), unref: vi.fn() })) daemonClientMock.mockClear() await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalled() // 'rejected' gets no grace window (probed once): count = initial adoption + rejected probe + fresh daemon lease. expect(daemonClientMock).toHaveBeenCalledTimes(3) }) it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => { // Why: startedAtMs null (all pre-fix Windows pid files) → identity 'unknown'; a live daemon must ADOPT, not replace. const mod = await importFresh() await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> getDaemonLaunchIdentityMock.mockReturnValueOnce('unknown') isPackagedMock.mockReturnValue(true) await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) it('accepts the daemon self-reported start time when the OS query returns null', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') getProcessStartedAtMsMock.mockReturnValue(null) launchedStartedAtMs.current = 1_700_000_123_456 forkMock.mockImplementationOnce(() => ({ pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_700_000_123_456 })) } return this }, off() { return this }, disconnect: vi.fn(), unref: vi.fn() })) await launcher('/fake/socket', '/fake/token') expect(writeFileSyncMock).not.toHaveBeenCalled() }) it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { // Why: deleting a live legacy daemon's token file makes its sessions permanently unadoptable. const mod = await importFresh() readFileSyncMock.mockReturnValue('{"pid":123}') // process.pid is guaranteed alive, so the liveness probe succeeds. parseDaemonPidFileMock.mockReturnValue({ pid: process.pid, startedAtMs: null }) await mod.initDaemonPtyProvider() const legacyUnlinks = unlinkSyncMock.mock.calls.filter( ([p]) => typeof p === 'string' && (p.includes('.token') || p.includes('.pid')) ) expect(legacyUnlinks).toEqual([]) }) it('cleans up legacy daemon pid/token files when the probe fails and the process is gone', async () => { const mod = await importFresh() readFileSyncMock.mockReturnValue('{"pid":123}') // Why: spy process.kill to force a deterministic ESRCH instead of relying on an unallocated real pid. const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH') }) parseDaemonPidFileMock.mockReturnValue({ pid: 999_999, startedAtMs: null }) try { await mod.initDaemonPtyProvider() } finally { killSpy.mockRestore() } const tokenUnlinks = unlinkSyncMock.mock.calls.filter( ([p]) => typeof p === 'string' && p.includes('.token') ) expect(tokenUnlinks.length).toBeGreaterThan(0) }) it('replaces a health-check-failing daemon when no live sessions would be lost', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') forkMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { message: [], error: [], exit: [] } return { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off(event: string, cb: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, disconnect: vi.fn(), unref: vi.fn() } }) await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, expect.arrayContaining([ '--socket', '/fake/socket', '--token', '/fake/token', '--log-file', join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') ]), expect.objectContaining({ detached: true }) ) }) it('preserves a packaged healthy daemon when its app bundle is current', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> getDaemonLaunchIdentityMock.mockClear() killStaleDaemonMock.mockClear() forkMock.mockClear() isPackagedMock.mockReturnValue(true) await launcher('/fake/socket', '/fake/token') expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', FAKE_DAEMON_ENTRY_PATH ) expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', '1.2.3' ) expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) it('respawns a packaged daemon that predates the current app bundle', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> isPackagedMock.mockReturnValue(true) isDaemonStaleForCurrentBundleMock.mockReturnValueOnce(true) forkMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { message: [], error: [], exit: [] } return { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, off(event: string, cb: (arg?: unknown) => void) { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return this }, disconnect: vi.fn(), unref: vi.fn() } }) await launcher('/fake/socket', '/fake/token') expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', '1.2.3' ) expect(killStaleDaemonMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, expect.arrayContaining([ '--socket', '/fake/socket', '--token', '/fake/token', '--log-file', join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') ]), expect.objectContaining({ detached: true }) ) // STA-2376: stale-bundle replacement, emitted exactly once. expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) expect(trackDaemonReplacedMock).toHaveBeenCalledWith('stale_bundle', 0) }) 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() mockConnectedAdoptionClientOnce() 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) isDaemonStaleForCurrentBundleMock.mockReturnValueOnce(true) await launcher('/fake/socket', '/fake/token') expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith( FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', '1.2.3' ) expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) })