diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 904b3703085..6b5afd62cb5 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -11,7 +11,8 @@ type AdapterMock = DaemonPtyAdapter & { function createAdapter( label: string, sessions: string[] = [], - reconcileResult?: { alive: string[]; killed: string[] } + reconcileResult?: { alive: string[]; killed: string[] }, + opts: { listProcessesRejects?: boolean } = {} ): AdapterMock { const writes: { id: string; data: string }[] = [] const dataListeners: ((payload: { id: string; data: string }) => void)[] = [] @@ -22,13 +23,16 @@ function createAdapter( sessions.push(id) return { id } }), - listProcesses: vi.fn(async () => - sessions.map((id) => ({ + listProcesses: vi.fn(async () => { + if (opts.listProcessesRejects) { + throw new Error('discovery failed') + } + return sessions.map((id) => ({ id, cwd: '', title: label })) - ), + }), write: vi.fn((id: string, data: string) => { writes.push({ id, data }) }), @@ -218,6 +222,23 @@ describe('DaemonPtyRouter', () => { expect(onLegacyDrained).toHaveBeenCalledWith(legacy) }) + it('does not drain an adapter when legacy discovery fails', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session'], undefined, { + listProcessesRejects: true + }) + const onLegacyDrained = vi.fn() + const router = new DaemonPtyRouter({ current, legacy: [legacy], onLegacyDrained }) + + await router.discoverLegacySessions() + router.sweepDrainedLegacyAdapters() + legacy.emitExit('legacy-session', 0) + + expect(onLegacyDrained).not.toHaveBeenCalled() + await router.listProcesses() + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) + }) + it('merges startup reconciliation and updates route mappings', async () => { const current = createAdapter('current', [], { alive: ['current-alive'], diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index bd5e2432f2b..61346a6155a 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -6,6 +6,7 @@ export class DaemonPtyRouter implements IPtyProvider { private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() private unsubscribersByAdapter = new Map void)[]>() + private discoveryFailedAdapters = new Set() private onLegacyDrained?: (adapter: DaemonPtyAdapter) => void private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] @@ -42,10 +43,12 @@ export class DaemonPtyRouter implements IPtyProvider { for (const adapter of this.legacy) { try { const sessions = await adapter.listProcesses() + this.discoveryFailedAdapters.delete(adapter) for (const session of sessions) { this.sessionAdapters.set(session.id, adapter) } } catch (error) { + this.discoveryFailedAdapters.add(adapter) console.warn('[daemon] Failed to discover legacy daemon sessions', error) } } @@ -204,10 +207,15 @@ export class DaemonPtyRouter implements IPtyProvider { private drainLegacyAdapterIfEmpty(adapter: DaemonPtyAdapter): void { // Why: only previous-version adapters are cleaned up here. The current // daemon lifecycle must remain byte-identical when its sessions exit. - if (!this.legacy.includes(adapter) || this.hasActiveSessionsOn(adapter)) { + if ( + !this.legacy.includes(adapter) || + this.discoveryFailedAdapters.has(adapter) || + this.hasActiveSessionsOn(adapter) + ) { return } this.legacy = this.legacy.filter((a) => a !== adapter) + this.discoveryFailedAdapters.delete(adapter) this.unsubscribeAdapter(adapter) this.onLegacyDrained?.(adapter) }