mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
262 lines
9.0 KiB
TypeScript
262 lines
9.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
PortScanner,
|
|
SSH_PORT_SCAN_BASE_INTERVAL_MS,
|
|
SSH_PORT_SCAN_MAX_INTERVAL_MS,
|
|
type PortScannerWindowVisibility
|
|
} from './ssh-port-scanner'
|
|
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
|
|
import type { DetectedPort } from '../../shared/ssh-types'
|
|
|
|
type VisibilityHarness = {
|
|
visibility: PortScannerWindowVisibility
|
|
setVisible: (visible: boolean) => void
|
|
listenerCount: () => number
|
|
}
|
|
|
|
function createVisibilityHarness(initiallyVisible: boolean): VisibilityHarness {
|
|
let visible = initiallyVisible
|
|
const listeners = new Set<() => void>()
|
|
return {
|
|
visibility: {
|
|
isWindowVisible: () => visible,
|
|
onWindowBecameVisible: (listener) => {
|
|
listeners.add(listener)
|
|
return () => {
|
|
listeners.delete(listener)
|
|
}
|
|
}
|
|
},
|
|
setVisible: (next) => {
|
|
const wasVisible = visible
|
|
visible = next
|
|
if (!wasVisible && next) {
|
|
for (const listener of Array.from(listeners)) {
|
|
listener()
|
|
}
|
|
}
|
|
},
|
|
listenerCount: () => listeners.size
|
|
}
|
|
}
|
|
|
|
function port(portNumber: number): DetectedPort {
|
|
return { port: portNumber, host: '127.0.0.1', pid: 100 + portNumber, processName: 'node' }
|
|
}
|
|
|
|
function createMux(ports: () => DetectedPort[]): {
|
|
mux: SshChannelMultiplexer
|
|
request: ReturnType<typeof vi.fn>
|
|
} {
|
|
const request = vi.fn(async () => ({ ports: ports(), platform: 'linux' }))
|
|
return { mux: { request } as unknown as SshChannelMultiplexer, request }
|
|
}
|
|
|
|
const BASE = SSH_PORT_SCAN_BASE_INTERVAL_MS
|
|
const MAX = SSH_PORT_SCAN_MAX_INTERVAL_MS
|
|
|
|
describe('PortScanner', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('sends zero ports.detect requests while the window is hidden, then scans immediately on show', async () => {
|
|
const harness = createVisibilityHarness(false)
|
|
const { mux, request } = createMux(() => [port(3000)])
|
|
const scanner = new PortScanner(harness.visibility)
|
|
scanner.startScanning('t1', mux, vi.fn())
|
|
|
|
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
|
expect(request).not.toHaveBeenCalled()
|
|
|
|
harness.setVisible(true)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
|
|
scanner.dispose()
|
|
})
|
|
|
|
it('polls at the base cadence while visible when results keep changing', async () => {
|
|
const harness = createVisibilityHarness(true)
|
|
let next = 3000
|
|
const { mux, request } = createMux(() => [port(next++)])
|
|
const scanner = new PortScanner(harness.visibility)
|
|
scanner.startScanning('t1', mux, vi.fn())
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
|
|
// 5 simulated base intervals -> 5 more scans (no backoff while changing).
|
|
await vi.advanceTimersByTimeAsync(5 * BASE)
|
|
expect(request).toHaveBeenCalledTimes(6)
|
|
|
|
scanner.dispose()
|
|
})
|
|
|
|
it('doubles the interval up to the cap while unchanged and resets to base on a change', async () => {
|
|
const harness = createVisibilityHarness(true)
|
|
let ports = [port(3000)]
|
|
const { mux, request } = createMux(() => ports)
|
|
const onChanged = vi.fn()
|
|
const scanner = new PortScanner(harness.visibility)
|
|
scanner.startScanning('t1', mux, onChanged)
|
|
|
|
// t=0: first scan is a change (empty -> {3000}) and stays at base.
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
expect(onChanged).toHaveBeenCalledTimes(1)
|
|
|
|
// t=12s: unchanged -> next wait doubles to 24s.
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(request).toHaveBeenCalledTimes(2)
|
|
|
|
// t=24s: nothing (waiting until t=36s).
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(request).toHaveBeenCalledTimes(2)
|
|
|
|
// t=36s: unchanged -> next wait caps at 30s (not 48s).
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(request).toHaveBeenCalledTimes(3)
|
|
|
|
// t=65.999s: still waiting.
|
|
await vi.advanceTimersByTimeAsync(MAX - 1)
|
|
expect(request).toHaveBeenCalledTimes(3)
|
|
|
|
// t=66s: unchanged -> next wait remains capped at 30s.
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(request).toHaveBeenCalledTimes(4)
|
|
|
|
await vi.advanceTimersByTimeAsync(MAX - 1)
|
|
expect(request).toHaveBeenCalledTimes(4)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(request).toHaveBeenCalledTimes(5)
|
|
expect(onChanged).toHaveBeenCalledTimes(1)
|
|
|
|
// A changed result resets the cadence back to base.
|
|
ports = [port(3000), port(4000)]
|
|
await vi.advanceTimersByTimeAsync(MAX)
|
|
expect(request).toHaveBeenCalledTimes(6)
|
|
expect(onChanged).toHaveBeenCalledTimes(2)
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(request).toHaveBeenCalledTimes(7)
|
|
expect(scanner.getDetectedPorts('t1').map((p) => p.port)).toEqual([3000, 4000])
|
|
|
|
scanner.dispose()
|
|
})
|
|
|
|
it('resets backoff and scans immediately when scanning restarts (reconnect/session-ready)', async () => {
|
|
const harness = createVisibilityHarness(true)
|
|
const { mux, request } = createMux(() => [port(3000)])
|
|
const scanner = new PortScanner(harness.visibility)
|
|
const onChanged = vi.fn()
|
|
scanner.startScanning('t1', mux, onChanged)
|
|
|
|
// Back off: scans at t=0, t=12s, t=36s -> interval now 48s.
|
|
await vi.advanceTimersByTimeAsync(3 * BASE)
|
|
expect(request).toHaveBeenCalledTimes(3)
|
|
|
|
// Reconnect paths call startScanning again: immediate scan, base cadence.
|
|
scanner.startScanning('t1', mux, onChanged)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(request).toHaveBeenCalledTimes(4)
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(request).toHaveBeenCalledTimes(5)
|
|
|
|
scanner.dispose()
|
|
})
|
|
|
|
it('parks when the window hides mid-run and resumes with an immediate scan on show', async () => {
|
|
const harness = createVisibilityHarness(true)
|
|
let next = 3000
|
|
const { mux, request } = createMux(() => [port(next++)])
|
|
const scanner = new PortScanner(harness.visibility)
|
|
scanner.startScanning('t1', mux, vi.fn())
|
|
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(request).toHaveBeenCalledTimes(2)
|
|
|
|
harness.setVisible(false)
|
|
await vi.advanceTimersByTimeAsync(30 * 60_000)
|
|
expect(request).toHaveBeenCalledTimes(2)
|
|
|
|
harness.setVisible(true)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(request).toHaveBeenCalledTimes(3)
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(request).toHaveBeenCalledTimes(4)
|
|
|
|
scanner.dispose()
|
|
})
|
|
|
|
it('never overlaps a slow in-flight request and resumes the chain after it settles', async () => {
|
|
const harness = createVisibilityHarness(true)
|
|
let resolveFirst: ((value: { ports: DetectedPort[]; platform: string }) => void) | null = null
|
|
const request = vi.fn(
|
|
() =>
|
|
new Promise<{ ports: DetectedPort[]; platform: string }>((resolve) => {
|
|
resolveFirst = resolve
|
|
})
|
|
)
|
|
const scanner = new PortScanner(harness.visibility)
|
|
scanner.startScanning('t1', { request } as unknown as SshChannelMultiplexer, vi.fn())
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
|
|
// The chain waits for the in-flight request instead of stacking more.
|
|
await vi.advanceTimersByTimeAsync(10 * BASE)
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
|
|
resolveFirst!({ ports: [port(3000)], platform: 'linux' })
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(request).toHaveBeenCalledTimes(2)
|
|
|
|
scanner.dispose()
|
|
})
|
|
|
|
it('stopScanning halts polling and detaches the visibility listener', async () => {
|
|
const harness = createVisibilityHarness(true)
|
|
const { mux, request } = createMux(() => [port(3000)])
|
|
const scanner = new PortScanner(harness.visibility)
|
|
scanner.startScanning('t1', mux, vi.fn())
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
expect(harness.listenerCount()).toBe(1)
|
|
|
|
scanner.stopScanning('t1')
|
|
expect(harness.listenerCount()).toBe(0)
|
|
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
|
harness.setVisible(false)
|
|
harness.setVisible(true)
|
|
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
expect(scanner.getDetectedPorts('t1')).toEqual([])
|
|
})
|
|
|
|
it('keeps targets independent: stopping one host leaves the other scanning', async () => {
|
|
const harness = createVisibilityHarness(true)
|
|
let nextA = 3000
|
|
let nextB = 4000
|
|
const a = createMux(() => [port(nextA++)])
|
|
const b = createMux(() => [port(nextB++)])
|
|
const scanner = new PortScanner(harness.visibility)
|
|
scanner.startScanning('host-a', a.mux, vi.fn())
|
|
scanner.startScanning('host-b', b.mux, vi.fn())
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(a.request).toHaveBeenCalledTimes(1)
|
|
expect(b.request).toHaveBeenCalledTimes(1)
|
|
|
|
scanner.stopScanning('host-a')
|
|
await vi.advanceTimersByTimeAsync(BASE)
|
|
expect(a.request).toHaveBeenCalledTimes(1)
|
|
expect(b.request).toHaveBeenCalledTimes(2)
|
|
|
|
scanner.dispose()
|
|
await vi.advanceTimersByTimeAsync(10 * BASE)
|
|
expect(b.request).toHaveBeenCalledTimes(2)
|
|
expect(harness.listenerCount()).toBe(0)
|
|
})
|
|
})
|