Files
orca/src/main/ssh/ssh-connection-manager.test.ts
T
Jinjing 00867f06e2 fix(ssh): handle owner displacement and graceful shutdown (#12367)
* fix(ssh): handle owner displacement and graceful shutdown

SSH connections can reconnect with valid session proof after network loss or
device sleep. When the incumbent owner is still half-open, allow the
reconnecting client to displace it outright rather than wait for socket closure
— a window that may never close. Retain displaced deliveries for the new owner
to rotate. During app shutdown, drain SSH sessions without terminating recovery
operations, and retry pending owner grants in case a replacement commits mid-drain.

* fix(ssh): handle owner displacement and graceful shutdown

Make QuitTeardownStartGate a shared singleton so SSH connects use the
same shutdown fence as the main quit path. Track test-connection probes
to ensure they complete before final teardown. Guard owner displacement
to prevent stale owners from clearing recovery state claimed by newer
owners.

* fix(ssh): fix flaky test sync and add error code safety check

Test was using tick-based Promise.resolve() loops which don't guarantee
the async operation has started. Replace with signal-based synchronization
that waits for the actual lease flush. Also add nullish-coalescing to
error code check to prevent crashes if error is null or undefined.

* fix(ssh): fence reset transport opens during shutdown

* fix(ssh): keep recovery leases stable across reconnects

* fix(ssh): close transports owned by cancelled connect attempts

When a connect is cancelled after its transport has opened, that cancelled
attempt still owns the transport and must close it — otherwise it leaks. Add
disconnectConnection() to close by identity (not by target ID) so a cancelled
attempt closes only the transport it minted, without tearing down its
replacement's live transport. Track priorConnection to detect whether this
attempt opened a new transport or reused an existing one, and close only on
abandonment if this attempt owns the session.

* fix(ssh): fence old owner proofs and close superseded transports

When an owner reconnects with a new proof while an old one is still live,
the old proof is now fenced with SUPERSEDED_ERROR instead of retrying
indefinitely. The relay also closes stale transports to signal that their
recovery generation has been overtaken by a newer one.

This ensures overlapping reconnect scenarios complete with the newest proof
rather than getting blocked by stale recovery attempts.

* fix(relay): re-pin stdin/stdout fds after closing to prevent recycling

When the relay closes stdin/stdout to signal EOF to the SSH peer, the OS
can recycle those fds (0 and 1) for new sockets or files. If Node still
treats process.stdin/stdout as those numbers, subsequent operations
corrupt socket clients and trigger shutdown errors. Re-pin the fds by
opening /dev/null to keep them occupied and prevent recycling.
2026-08-04 01:37:25 -07:00

130 lines
3.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshTarget } from '../../shared/ssh-types'
const mockState = vi.hoisted(() => ({
connectResults: [] as Promise<void>[],
instances: [] as {
connect: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
status: 'connecting' | 'connected' | 'disconnected'
}[]
}))
vi.mock('./ssh-connection', () => ({
SshConnection: class MockSshConnection {
status: 'connecting' | 'connected' | 'disconnected' = 'connecting'
connect = vi.fn(async () => {
await (mockState.connectResults.shift() ?? Promise.resolve())
this.status = 'connected'
})
disconnect = vi.fn(async () => {
this.status = 'disconnected'
})
constructor() {
mockState.instances.push(this)
}
getState(): { status: 'connecting' | 'connected' | 'disconnected' } {
return { status: this.status }
}
setCallbacks(): void {}
}
}))
import { SshConnectionManager } from './ssh-connection-manager'
const target = {
id: 'target-1',
label: 'Target 1',
host: 'example.test',
port: 22,
username: 'demo',
source: 'manual'
} as SshTarget
describe('SshConnectionManager', () => {
beforeEach(() => {
mockState.connectResults.length = 0
mockState.instances.length = 0
})
it('lets disconnect start a new connect before the cancelled attempt settles', async () => {
let rejectFirst!: (error: Error) => void
mockState.connectResults.push(
new Promise<void>((_resolve, reject) => {
rejectFirst = reject
}),
Promise.resolve()
)
const manager = new SshConnectionManager({
onStateChange: vi.fn()
})
const firstConnect = manager.connect(target)
await manager.disconnect(target.id)
const secondConnection = await manager.connect(target)
rejectFirst(new Error('cancelled'))
await expect(firstConnect).rejects.toThrow('cancelled')
expect(mockState.instances).toHaveLength(2)
expect(manager.getConnection(target.id)).toBe(secondConnection)
})
it('keeps the replacement when the disconnected attempt resolves late', async () => {
let resolveFirst!: () => void
mockState.connectResults.push(
new Promise<void>((resolve) => {
resolveFirst = resolve
}),
Promise.resolve()
)
const manager = new SshConnectionManager({
onStateChange: vi.fn()
})
const firstConnect = manager.connect(target)
await manager.disconnect(target.id)
const replacement = await manager.connect(target)
resolveFirst()
await expect(firstConnect).resolves.not.toBe(replacement)
expect(mockState.instances[0].disconnect).toHaveBeenCalledOnce()
expect(await manager.connect(target)).toBe(replacement)
expect(manager.getConnection(target.id)).toBe(replacement)
})
it('closes a late-resolved connection without evicting the replacement', async () => {
let resolveFirst!: () => void
mockState.connectResults.push(
new Promise<void>((resolve) => {
resolveFirst = resolve
}),
Promise.resolve()
)
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
const firstConnect = manager.connect(target)
await manager.disconnect(target.id)
const replacement = await manager.connect(target)
resolveFirst()
const late = await firstConnect
await manager.disconnectConnection(target.id, late)
expect(mockState.instances[0].disconnect).toHaveBeenCalledTimes(2)
expect(manager.getConnection(target.id)).toBe(replacement)
})
it('clears the pool entry when the closed connection is still the registered one', async () => {
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
const conn = await manager.connect(target)
await manager.disconnectConnection(target.id, conn)
expect(mockState.instances[0].disconnect).toHaveBeenCalledOnce()
expect(manager.getConnection(target.id)).toBeUndefined()
})
})