mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
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 <help@stably.ai>
This commit is contained in:
@@ -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<boolean | null>
|
||||
@@ -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<typeof createDaemonAdapter>
|
||||
fallback: ReturnType<typeof createProvider>
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user