From 246da423db57e756d50a88c0e9129f134ba308da Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:40:54 -0700 Subject: [PATCH] test(daemon): pin that a held session cannot be closed by a provider that never had it Covers the held-daemon routing fence, including the coupling that is invisible from the routing file: the thrown error must not match pty:kill's already-gone predicate, or the close is swallowed into a synthesized exit and the orphan is hidden again. A rename would otherwise reintroduce the bug silently. Co-authored-by: Orca --- .../degraded-daemon-pty-provider.test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 6b473537e9f..8c38ced2740 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -5,6 +5,7 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' type ProviderMock = IPtyProvider & { probePtyLiveness: (id: string) => Promise @@ -647,6 +648,27 @@ describe('DegradedDaemonPtyProvider', () => { expect(provider.hasPty('legacy-session')).toBe(true) }) + it('routes every session operation for a mapped daemon session to that adapter', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.discoverDaemonSessions() + + provider.write('daemon-session', 'ls\n') + provider.resize('daemon-session', 120, 40) + await provider.sendSignal('daemon-session', 'SIGINT') + await provider.shutdown('daemon-session', {}) + + expect(current.write).toHaveBeenCalledWith('daemon-session', 'ls\n') + expect(current.resize).toHaveBeenCalledWith('daemon-session', 120, 40) + expect(current.sendSignal).toHaveBeenCalledWith('daemon-session', 'SIGINT') + expect(current.shutdown).toHaveBeenCalledWith('daemon-session', {}) + expect(fallback.write).not.toHaveBeenCalled() + expect(fallback.resize).not.toHaveBeenCalled() + expect(fallback.sendSignal).not.toHaveBeenCalled() + expect(fallback.shutdown).not.toHaveBeenCalled() + }) + it('keeps an exited legacy daemon poisoning listProcesses after construction', async () => { const current = createDaemonAdapter('daemon', ['current-session']) const legacy = createDaemonAdapter('legacy', ['legacy-session']) @@ -662,3 +684,102 @@ describe('DegradedDaemonPtyProvider', () => { expect(fallback.listProcesses).toHaveBeenCalledTimes(3) }) }) + +describe('DegradedDaemonPtyProvider with a held daemon', () => { + const HELD_SESSION = 'wt-1@@held-daemon-session' + + /** Held launch mode never connects to the wedged daemon, so discovery maps nothing and + * every daemon-owned id is unrouted — i.e. resolves to the in-process fallback. */ + function createHeldDaemonProvider(): { + current: ReturnType + fallback: ReturnType + provider: DegradedDaemonPtyProvider + } { + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback') + return { + current, + fallback, + provider: new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + } + } + + it('rejects shutdown for a held daemon session instead of reporting a silent success', async () => { + const { current, fallback, provider } = createHeldDaemonProvider() + await provider.discoverDaemonSessions() + + // Why: the fallback's shutdown resolves for ids it never had, so the pane would close + // while the daemon's agent keeps running as an orphan. + await expect(provider.shutdown(HELD_SESSION, {})).rejects.toBeInstanceOf( + TerminalSessionOwnerUnverifiedError + ) + expect(fallback.shutdown).not.toHaveBeenCalled() + expect(current.shutdown).not.toHaveBeenCalled() + }) + + it('does not let the kill path mistake an unreachable owner for an already-gone pty', async () => { + const { provider } = createHeldDaemonProvider() + await provider.discoverDaemonSessions() + // Mirrors pty:kill's isPtyAlreadyGoneError (src/main/ipc/pty.ts), which is not exported: + // any error matching it is swallowed into a synthesized pty:exit and reported as success — + // exactly the orphan-hiding lie this routing exists to prevent. Renaming the thrown error + // back into that shape would silently reintroduce it. + const looksAlreadyGoneToPtyKill = (error: unknown): boolean => + isSshPtyNotFoundError(error) || + /Session not found/i.test(error instanceof Error ? error.message : String(error)) + + const error = await provider.shutdown(HELD_SESSION, {}).catch((err: unknown) => err) + + expect(error).toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(looksAlreadyGoneToPtyKill(error)).toBe(false) + }) + + it('throws on write and resize for a held daemon session instead of swallowing input', async () => { + const { fallback, provider } = createHeldDaemonProvider() + await provider.discoverDaemonSessions() + + // Why: the fallback's write/resize are `ptyProcesses.get(id)?.…` — typing would vanish. + expect(() => provider.write(HELD_SESSION, 'ls\n')).toThrow(TerminalSessionOwnerUnverifiedError) + expect(() => provider.resize(HELD_SESSION, 120, 40)).toThrow( + TerminalSessionOwnerUnverifiedError + ) + expect(fallback.write).not.toHaveBeenCalled() + expect(fallback.resize).not.toHaveBeenCalled() + }) + + it('rejects sendSignal for a held daemon session', async () => { + const { fallback, provider } = createHeldDaemonProvider() + await provider.discoverDaemonSessions() + + await expect(provider.sendSignal(HELD_SESSION, 'SIGINT')).rejects.toBeInstanceOf( + TerminalSessionOwnerUnverifiedError + ) + expect(fallback.sendSignal).not.toHaveBeenCalled() + }) + + it('keeps refusing attach for a held daemon session', async () => { + const { fallback, provider } = createHeldDaemonProvider() + await provider.discoverDaemonSessions() + + await expect(provider.attach(HELD_SESSION)).rejects.toBeInstanceOf(SessionNotFoundError) + expect(fallback.attach).not.toHaveBeenCalled() + }) + + it('still routes every operation for a locally spawned session the fallback owns', async () => { + const { current, fallback, provider } = createHeldDaemonProvider() + await provider.discoverDaemonSessions() + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + + provider.write(fresh.id, 'echo hi\n') + provider.resize(fresh.id, 100, 30) + await expect(provider.sendSignal(fresh.id, 'SIGINT')).resolves.toBeUndefined() + await expect(provider.shutdown(fresh.id, {})).resolves.toBeUndefined() + + expect(fallback.write).toHaveBeenCalledWith(fresh.id, 'echo hi\n') + expect(fallback.resize).toHaveBeenCalledWith(fresh.id, 100, 30) + expect(fallback.sendSignal).toHaveBeenCalledWith(fresh.id, 'SIGINT') + expect(fallback.shutdown).toHaveBeenCalledWith(fresh.id, {}) + expect(current.write).not.toHaveBeenCalled() + expect(current.shutdown).not.toHaveBeenCalled() + }) +})