| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- /**
- * ConnectionController: strict readiness handshake (describe + incremental
- * source ready), generation
- * abort on loss, backoff reconnection, state transitions, and sink-exception
- * isolation. Real (short) timers — the timeout and backoff are configurable,
- * so tests run them at millisecond scale.
- */
- import { describe, expect, it, vi } from 'vitest'
- import type { ConnectionState } from '../src/client/connection.ts'
- import { ConnectionController } from '../src/client/connection.ts'
- import { FakeApiClient, deferred, ok } from './fake-api.client.ts'
- const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }
- describe('connection lifecycle', () => {
- it('announces connected after describe plus generation readiness', async () => {
- const api = new FakeApiClient()
- const descriptions: boolean[] = []
- let connected = 0
- const controller = new ConnectionController(api, api.generation, {
- onConnected: (description) => {
- connected++
- descriptions.push(description.canOpenPath)
- },
- }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(connected).toBe(1) })
- expect(api.callsOf('host.describe')).toHaveLength(1)
- expect(descriptions).toEqual([true])
- } finally {
- controller.stop()
- }
- })
- it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => {
- const api = new FakeApiClient()
- let connected = 0
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(connected).toBe(1) })
- api.failStreams(new Error('stream torn'))
- await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff
- expect(api.openGenerationCount).toBe(1)
- } finally {
- controller.stop()
- warnSpy.mockRestore()
- }
- // stop() aborts the live generation and no reconnect follows.
- await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) })
- await new Promise(resolve => setTimeout(resolve, 40))
- expect(api.openGenerationCount).toBe(0)
- })
- it('treats describe failure as generation failure and retries', async () => {
- const api = new FakeApiClient()
- const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
- let describeCalls = 0
- api.onDescribe = () => {
- describeCalls++
- return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise
- }
- let connected = 0
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
- expect(connected).toBe(0) // never announced during the failed generation
- gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
- await vi.waitFor(() => { expect(connected).toBe(1) })
- } finally {
- controller.stop()
- warnSpy.mockRestore()
- }
- })
- it('treats a host.describe business error as generation failure', async () => {
- const api = new FakeApiClient()
- let describeCalls = 0
- api.onDescribe = () => {
- describeCalls += 1
- if (describeCalls === 1) {
- return Promise.resolve({
- rpcId: 'bad-describe' as never,
- result: {
- ok: false as const,
- error: { code: 'internal' as const, message: 'not ready', details: {} },
- },
- })
- }
- return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
- }
- let connected = 0
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(describeCalls).toBe(2) })
- await vi.waitFor(() => { expect(connected).toBe(1) })
- } finally {
- controller.stop()
- warnSpy.mockRestore()
- }
- })
- it('isolates a connected sink exception from the generation', async () => {
- const api = new FakeApiClient()
- let connected = 0
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
- const controller = new ConnectionController(api, api.generation, {
- onConnected: () => {
- connected++
- throw new Error('business layer bug')
- },
- }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(connected).toBe(1) })
- expect(api.openGenerationCount).toBe(1)
- expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error))
- } finally {
- controller.stop()
- errorSpy.mockRestore()
- }
- })
- it('holds onConnected until the incremental source is ready after describe succeeds', async () => {
- const api = new FakeApiClient()
- api.holdGenerationReady = true
- let connected = 0
- const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
- await new Promise(resolve => setTimeout(resolve, 30))
- expect(connected).toBe(0) // describe alone must not announce
- api.releaseGenerationReady()
- await vi.waitFor(() => { expect(connected).toBe(1) })
- } finally {
- controller.stop()
- }
- })
- it('rejects a generation whose source ends during readiness and retries', async () => {
- const api = new FakeApiClient()
- const firstDescribe = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
- let describeCalls = 0
- api.onDescribe = () => {
- describeCalls++
- return describeCalls === 1
- ? firstDescribe.promise
- : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
- }
- const states: ConnectionState[] = []
- let connected = 0
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const controller = new ConnectionController(api, api.generation, {
- onConnected: () => { connected++ },
- onStateChange: state => states.push(state),
- }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) })
- api.endStreams()
- firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
- await vi.waitFor(() => { expect(describeCalls).toBe(2) })
- await vi.waitFor(() => { expect(connected).toBe(1) })
- expect(states).toEqual(['reconnecting', 'connected'])
- } finally {
- controller.stop()
- warnSpy.mockRestore()
- }
- })
- it.each([
- { label: 'ends normally', fail: () => Promise.resolve() },
- {
- label: 'rejects with a non-Error reason',
- // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario.
- fail: () => Promise.reject('fixture offline'),
- },
- ])('retries when the generation source $label before reporting ready', async ({ fail }) => {
- const api = new FakeApiClient()
- let sourceCalls = 0
- let connected = 0
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const controller = new ConnectionController(api, (signal, ready) => {
- sourceCalls++
- if (sourceCalls === 1) return fail()
- ready()
- return new Promise<void>((resolve) => {
- signal.addEventListener('abort', () => { resolve() }, { once: true })
- })
- }, { onConnected: () => { connected++ } }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(sourceCalls).toBe(2) })
- await vi.waitFor(() => { expect(connected).toBe(1) })
- } finally {
- controller.stop()
- warnSpy.mockRestore()
- }
- })
- it('rejects and retries a generation whose source never reports ready', async () => {
- const api = new FakeApiClient()
- api.suppressGenerationReady = true
- let connected = 0
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const controller = new ConnectionController(
- api,
- api.generation,
- { onConnected: () => { connected++ } },
- { ...FAST, generationReadyTimeoutMs: 20 },
- )
- controller.start()
- try {
- await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) })
- expect(connected).toBe(0)
- } finally {
- controller.stop()
- warnSpy.mockRestore()
- }
- })
- it('emits deduplicated connected/reconnecting state transitions', async () => {
- const api = new FakeApiClient()
- const states: ConnectionState[] = []
- let connected = 0
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const controller = new ConnectionController(api, api.generation, {
- onConnected: () => { connected++ },
- onStateChange: state => states.push(state),
- }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(connected).toBe(1) })
- expect(states).toEqual(['connected'])
- api.failStreams(new Error('torn'))
- await vi.waitFor(() => { expect(connected).toBe(2) })
- expect(states).toEqual(['connected', 'reconnecting', 'connected'])
- } finally {
- controller.stop()
- warnSpy.mockRestore()
- }
- })
- it('does not announce a generation stopped synchronously by its connected state sink', async () => {
- const api = new FakeApiClient()
- const states: ConnectionState[] = []
- let connected = 0
- const controller = new ConnectionController(api, api.generation, {
- onConnected: () => { connected++ },
- onStateChange: (state) => {
- states.push(state)
- if (state === 'connected') controller.stop()
- },
- }, FAST)
- controller.start()
- await vi.waitFor(() => { expect(states).toEqual(['connected']) })
- await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) })
- expect(connected).toBe(0)
- })
- it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
- const api = new FakeApiClient()
- const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
- let describeCalls = 0
- api.onDescribe = () => {
- describeCalls++
- return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
- }
- const states: ConnectionState[] = []
- let connected = 0
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const controller = new ConnectionController(api, api.generation, {
- onConnected: () => { connected++ },
- onStateChange: state => states.push(state),
- }, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(describeCalls).toBe(3) })
- gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
- await vi.waitFor(() => { expect(connected).toBe(1) })
- expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
- } finally {
- controller.stop()
- warnSpy.mockRestore()
- }
- })
- it('runs with no sinks at all (every callback slot optional)', async () => {
- const api = new FakeApiClient()
- const controller = new ConnectionController(api, api.generation, {}, FAST)
- controller.start()
- try {
- await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
- await new Promise(resolve => setTimeout(resolve, 20))
- } finally {
- controller.stop()
- }
- })
- it('start() is idempotent (one loop, one stream set)', async () => {
- const api = new FakeApiClient()
- let connected = 0
- const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
- controller.start()
- controller.start()
- try {
- await vi.waitFor(() => { expect(connected).toBe(1) })
- expect(api.openGenerationCount).toBe(1)
- expect(api.callsOf('host.describe')).toHaveLength(1)
- } finally {
- controller.stop()
- }
- })
- })
|