mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const mocks = await vi.hoisted(async () => {
|
|
const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks')
|
|
return createSshIpcMocks()
|
|
})
|
|
|
|
vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker)
|
|
vi.mock('electron', () => mocks.electron)
|
|
vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry)
|
|
vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
|
|
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
|
|
vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy)
|
|
vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset)
|
|
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
|
|
vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider)
|
|
vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider)
|
|
vi.mock('./pty', () => mocks.pty)
|
|
vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch)
|
|
vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider)
|
|
vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch)
|
|
vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward)
|
|
vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner)
|
|
|
|
import { getActiveMultiplexer } from './ssh'
|
|
import {
|
|
beginSshShutdown,
|
|
SSH_SHUTDOWN_BUDGET_MS,
|
|
type SshShutdownResult
|
|
} from './ssh-shutdown-drain'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import { quitTeardownStartGate } from '../quit-teardown-start-gate'
|
|
import { createSshIpcHarness } from './ssh-ipc-test-harness'
|
|
|
|
const { mockSshStore, mockConnectionManager, mockMux, mockPortForwardManager } = mocks
|
|
|
|
describe('SSH IPC handlers', () => {
|
|
const harness = createSshIpcHarness(mocks)
|
|
const { handlers, mockStore, mockWindow } = harness
|
|
|
|
beforeEach(harness.reset)
|
|
|
|
it('detaches active SSH sessions during app shutdown without terminating recovery', async () => {
|
|
const target: SshTarget = {
|
|
id: 'ssh-1',
|
|
label: 'Server',
|
|
host: 'example.com',
|
|
port: 22,
|
|
username: 'deploy'
|
|
}
|
|
mockSshStore.getTarget.mockReturnValue(target)
|
|
mockConnectionManager.connect.mockResolvedValue({})
|
|
mockConnectionManager.getState.mockReturnValue({
|
|
targetId: 'ssh-1',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
})
|
|
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
|
mockMux.dispose.mockClear()
|
|
mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined)
|
|
|
|
await beginSshShutdown()
|
|
|
|
expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1')
|
|
expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost')
|
|
expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached')
|
|
expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled()
|
|
expect(mockConnectionManager.disconnectAll).toHaveBeenCalled()
|
|
})
|
|
|
|
it('detaches every lease in memory before a slow forward removal can cross the final flush', async () => {
|
|
const target: SshTarget = {
|
|
id: 'ssh-1',
|
|
label: 'Server',
|
|
host: 'example.com',
|
|
port: 22,
|
|
username: 'deploy'
|
|
}
|
|
mockSshStore.getTarget.mockReturnValue(target)
|
|
mockConnectionManager.connect.mockResolvedValue({})
|
|
mockConnectionManager.getState.mockReturnValue({
|
|
targetId: 'ssh-1',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
})
|
|
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
|
mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined)
|
|
let releaseForwards!: () => void
|
|
vi.mocked(mockPortForwardManager.removeAllForwards).mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
releaseForwards = resolve
|
|
})
|
|
)
|
|
vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear()
|
|
quitTeardownStartGate.tryStart({ preventDefault() {} })
|
|
|
|
const shutdown = beginSshShutdown()
|
|
|
|
// Why asserted with no await: the committed quit path calls store.flushAsync() on the very next
|
|
// line, so anything not already in memory at this instant can never reach the final snapshot.
|
|
expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-1', 'detached')
|
|
// Why 'detached' and never 'terminated': the app is letting go of the lease, not proving the
|
|
// remote shell died. Those PTYs keep running for the next attach.
|
|
expect(mockStore.markSshRemotePtyLeasesForShutdown).not.toHaveBeenCalledWith(
|
|
'ssh-1',
|
|
'terminated'
|
|
)
|
|
expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled()
|
|
expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1')
|
|
|
|
releaseForwards()
|
|
await shutdown
|
|
})
|
|
|
|
it('repeats no state transition when shutdown is begun twice', async () => {
|
|
const target: SshTarget = {
|
|
id: 'ssh-1',
|
|
label: 'Server',
|
|
host: 'example.com',
|
|
port: 22,
|
|
username: 'deploy'
|
|
}
|
|
mockSshStore.getTarget.mockReturnValue(target)
|
|
mockConnectionManager.connect.mockResolvedValue({})
|
|
mockConnectionManager.getState.mockReturnValue({
|
|
targetId: 'ssh-1',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
})
|
|
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
|
mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined)
|
|
vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear()
|
|
quitTeardownStartGate.tryStart({ preventDefault() {} })
|
|
|
|
const first = beginSshShutdown()
|
|
const second = beginSshShutdown()
|
|
|
|
expect(second).toBe(first)
|
|
await Promise.all([first, second])
|
|
expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledExactlyOnceWith(
|
|
'ssh-1',
|
|
'detached'
|
|
)
|
|
})
|
|
|
|
it('detaches the remaining sessions and still returns when one session throws mid-transition', async () => {
|
|
const targets: Record<string, SshTarget> = {
|
|
'ssh-1': { id: 'ssh-1', label: 'A', host: 'a.example.com', port: 22, username: 'deploy' },
|
|
'ssh-2': { id: 'ssh-2', label: 'B', host: 'b.example.com', port: 22, username: 'deploy' }
|
|
}
|
|
mockSshStore.getTarget.mockImplementation((id: string) => targets[id] ?? null)
|
|
mockConnectionManager.connect.mockResolvedValue({})
|
|
mockConnectionManager.getState.mockImplementation((targetId: string) => ({
|
|
targetId,
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
}))
|
|
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
|
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-2' })
|
|
mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined)
|
|
vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear()
|
|
// Why webContents.send: quit destroys the renderer, and that is what makes broadcastEmptyLists
|
|
// throw out of the pre-pass for whichever session reaches it first.
|
|
mockWindow.webContents.send.mockImplementation((_channel: string, payload: unknown) => {
|
|
if ((payload as { targetId?: string } | undefined)?.targetId === 'ssh-1') {
|
|
throw new Error('Object has been destroyed')
|
|
}
|
|
})
|
|
quitTeardownStartGate.tryStart({ preventDefault() {} })
|
|
|
|
// Why not-throw rather than a resolved promise: the caller is a non-async will-quit listener, so
|
|
// a synchronous throw escapes it and skips killAllPty, the watchers and store.flushAsync() — the
|
|
// very flush that persists the detached leases this pre-pass just staged.
|
|
let shutdown!: Promise<SshShutdownResult>
|
|
expect(() => {
|
|
shutdown = beginSshShutdown()
|
|
}).not.toThrow()
|
|
// Why asserted before the await: the real flush starts on the next synchronous line.
|
|
expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-2', 'detached')
|
|
|
|
const result = await shutdown
|
|
expect(
|
|
result.errors.some(
|
|
(error) => error instanceof Error && error.message === 'Object has been destroyed'
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('reports the target and phase left unfinished when the shutdown budget expires', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const target: SshTarget = {
|
|
id: 'ssh-1',
|
|
label: 'Server',
|
|
host: 'example.com',
|
|
port: 22,
|
|
username: 'deploy'
|
|
}
|
|
mockSshStore.getTarget.mockReturnValue(target)
|
|
mockConnectionManager.connect.mockResolvedValue({})
|
|
mockConnectionManager.getState.mockReturnValue({
|
|
targetId: 'ssh-1',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
})
|
|
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
|
mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined)
|
|
// Why never resolved, on every call: a forward whose child never reports exit is exactly the
|
|
// case the shared deadline exists for, and it must not get a fresh budget per phase.
|
|
vi.mocked(mockPortForwardManager.removeAllForwards).mockImplementation(
|
|
() => new Promise(() => {})
|
|
)
|
|
quitTeardownStartGate.tryStart({ preventDefault() {} })
|
|
|
|
let settled = false
|
|
const shutdown = beginSshShutdown().then((value) => {
|
|
settled = true
|
|
return value
|
|
})
|
|
// Why the whole budget and not a millisecond more: the entire drain/join/drain sequence has to
|
|
// fit inside one deadline, so advancing exactly that far must be enough to settle it. Asserting
|
|
// the flag rather than awaiting keeps a per-phase budget a failure instead of a hang.
|
|
await vi.advanceTimersByTimeAsync(SSH_SHUTDOWN_BUDGET_MS)
|
|
expect(settled).toBe(true)
|
|
const result = await shutdown
|
|
|
|
expect(result.unfinished).toContainEqual({ targetId: 'ssh-1', phase: 'drain' })
|
|
// Why no final-drain entry: the budget was already spent, so no later phase was awaited at all.
|
|
expect(result.unfinished.some((entry) => entry.phase === 'final-drain')).toBe(false)
|
|
// Why the lease still stands: the drain timing out says nothing about the remote PTYs, and the
|
|
// pre-pass already recorded the only thing that was ever provable.
|
|
expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-1', 'detached')
|
|
expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.mocked(mockPortForwardManager.removeAllForwards).mockReset().mockResolvedValue(undefined)
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('returns as soon as a fast shutdown drains rather than waiting out the budget', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined)
|
|
quitTeardownStartGate.tryStart({ preventDefault() {} })
|
|
|
|
const result = await beginSshShutdown()
|
|
|
|
expect(result.unfinished).toEqual([])
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('refuses the replacement session a paused connect would publish after shutdown began', async () => {
|
|
const target: SshTarget = {
|
|
id: 'ssh-1',
|
|
label: 'Server',
|
|
host: 'example.com',
|
|
port: 22,
|
|
username: 'deploy'
|
|
}
|
|
mockSshStore.getTarget.mockReturnValue(target)
|
|
mockConnectionManager.connect.mockResolvedValue({})
|
|
mockConnectionManager.getState.mockReturnValue({
|
|
targetId: 'ssh-1',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
})
|
|
mockConnectionManager.disconnectAll.mockResolvedValue(undefined)
|
|
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
|
|
|
// Why here: the old session's lease flush is the one window where doConnect resumes straight into
|
|
// publishing its replacement session without re-checking authority.
|
|
let releaseDetach = (): void => {}
|
|
const detachFlush = new Promise<void>((resolve) => {
|
|
releaseDetach = resolve
|
|
})
|
|
let signalEnteredDetach = (): void => {}
|
|
const enteredDetach = new Promise<void>((resolve) => {
|
|
signalEnteredDetach = resolve
|
|
})
|
|
mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce(() => {
|
|
signalEnteredDetach()
|
|
return detachFlush
|
|
})
|
|
|
|
const replacement = handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
|
// Why await the flush entry and not ticks: shutdown must not snapshot what to drain until the
|
|
// replacement connect is parked in the lease flush.
|
|
await enteredDetach
|
|
expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached')
|
|
|
|
mockConnectionManager.disconnectAll.mockClear()
|
|
// Why latch the gate here: the committed quit path owns it, so the drain alone must not fence.
|
|
quitTeardownStartGate.tryStart({ preventDefault() {} })
|
|
const shutdown = beginSshShutdown()
|
|
releaseDetach()
|
|
|
|
await expect(replacement).rejects.toThrow('closed for app shutdown')
|
|
await shutdown
|
|
|
|
// Why one detach: the fence sits at the publication point, so the resumed connect never registers a
|
|
// replacement session — only the old session it had already torn down was detached.
|
|
const detaches = mockStore.markSshRemotePtyLeasesAsync.mock.calls.filter(
|
|
(call) => call[1] === 'detached'
|
|
)
|
|
expect(detaches).toHaveLength(1)
|
|
// Why twice: once for the drain's snapshot, once after joining the connect that was still in flight.
|
|
expect(mockConnectionManager.disconnectAll).toHaveBeenCalledTimes(2)
|
|
expect(getActiveMultiplexer('ssh-1')).toBeUndefined()
|
|
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow(
|
|
'closed for app shutdown'
|
|
)
|
|
})
|
|
|
|
it('joins an in-flight test-connection probe before the final shutdown disconnect', async () => {
|
|
const target: SshTarget = {
|
|
id: 'ssh-probe',
|
|
label: 'Server',
|
|
host: 'example.com',
|
|
port: 22,
|
|
username: 'deploy'
|
|
}
|
|
mockSshStore.getTarget.mockReturnValue(target)
|
|
mockConnectionManager.getState.mockReturnValue({
|
|
targetId: 'ssh-probe',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
})
|
|
mockConnectionManager.disconnectAll.mockResolvedValue(undefined)
|
|
mockConnectionManager.disconnect.mockClear().mockResolvedValue(undefined)
|
|
|
|
// Why: a probe holds a transport no session owns, so shutdown has to wait for it to hand it back.
|
|
const probeState = {
|
|
targetId: 'ssh-probe',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
}
|
|
let openProbeTransport = (): void => {}
|
|
mockConnectionManager.connect.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
openProbeTransport = () => resolve({ getState: () => probeState })
|
|
})
|
|
)
|
|
const probe = handlers.get('ssh:testConnection')!(null, { targetId: 'ssh-probe' })
|
|
for (let tick = 0; tick < 5; tick++) {
|
|
await Promise.resolve()
|
|
}
|
|
expect(mockConnectionManager.connect).toHaveBeenCalledWith(target)
|
|
|
|
quitTeardownStartGate.tryStart({ preventDefault() {} })
|
|
const shutdown = beginSshShutdown()
|
|
openProbeTransport()
|
|
await shutdown
|
|
|
|
expect(await probe).toMatchObject({ success: true })
|
|
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-probe')
|
|
})
|
|
})
|