connection.client.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. /**
  2. * ConnectionController: strict readiness handshake (describe + incremental
  3. * source ready), generation
  4. * abort on loss, backoff reconnection, state transitions, and sink-exception
  5. * isolation. Real (short) timers — the timeout and backoff are configurable,
  6. * so tests run them at millisecond scale.
  7. */
  8. import { describe, expect, it, vi } from 'vitest'
  9. import type { ConnectionState } from '../src/client/connection.ts'
  10. import { ConnectionController } from '../src/client/connection.ts'
  11. import { FakeApiClient, deferred, ok } from './fake-api.client.ts'
  12. const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }
  13. describe('connection lifecycle', () => {
  14. it('announces connected after describe plus generation readiness', async () => {
  15. const api = new FakeApiClient()
  16. const descriptions: boolean[] = []
  17. let connected = 0
  18. const controller = new ConnectionController(api, api.generation, {
  19. onConnected: (description) => {
  20. connected++
  21. descriptions.push(description.canOpenPath)
  22. },
  23. }, FAST)
  24. controller.start()
  25. try {
  26. await vi.waitFor(() => { expect(connected).toBe(1) })
  27. expect(api.callsOf('host.describe')).toHaveLength(1)
  28. expect(descriptions).toEqual([true])
  29. } finally {
  30. controller.stop()
  31. }
  32. })
  33. it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => {
  34. const api = new FakeApiClient()
  35. let connected = 0
  36. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  37. const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
  38. controller.start()
  39. try {
  40. await vi.waitFor(() => { expect(connected).toBe(1) })
  41. api.failStreams(new Error('stream torn'))
  42. await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff
  43. expect(api.openGenerationCount).toBe(1)
  44. } finally {
  45. controller.stop()
  46. warnSpy.mockRestore()
  47. }
  48. // stop() aborts the live generation and no reconnect follows.
  49. await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) })
  50. await new Promise(resolve => setTimeout(resolve, 40))
  51. expect(api.openGenerationCount).toBe(0)
  52. })
  53. it('treats describe failure as generation failure and retries', async () => {
  54. const api = new FakeApiClient()
  55. const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
  56. let describeCalls = 0
  57. api.onDescribe = () => {
  58. describeCalls++
  59. return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise
  60. }
  61. let connected = 0
  62. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  63. const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
  64. controller.start()
  65. try {
  66. await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
  67. expect(connected).toBe(0) // never announced during the failed generation
  68. gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
  69. await vi.waitFor(() => { expect(connected).toBe(1) })
  70. } finally {
  71. controller.stop()
  72. warnSpy.mockRestore()
  73. }
  74. })
  75. it('treats a host.describe business error as generation failure', async () => {
  76. const api = new FakeApiClient()
  77. let describeCalls = 0
  78. api.onDescribe = () => {
  79. describeCalls += 1
  80. if (describeCalls === 1) {
  81. return Promise.resolve({
  82. rpcId: 'bad-describe' as never,
  83. result: {
  84. ok: false as const,
  85. error: { code: 'internal' as const, message: 'not ready', details: {} },
  86. },
  87. })
  88. }
  89. return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
  90. }
  91. let connected = 0
  92. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  93. const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
  94. controller.start()
  95. try {
  96. await vi.waitFor(() => { expect(describeCalls).toBe(2) })
  97. await vi.waitFor(() => { expect(connected).toBe(1) })
  98. } finally {
  99. controller.stop()
  100. warnSpy.mockRestore()
  101. }
  102. })
  103. it('isolates a connected sink exception from the generation', async () => {
  104. const api = new FakeApiClient()
  105. let connected = 0
  106. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  107. const controller = new ConnectionController(api, api.generation, {
  108. onConnected: () => {
  109. connected++
  110. throw new Error('business layer bug')
  111. },
  112. }, FAST)
  113. controller.start()
  114. try {
  115. await vi.waitFor(() => { expect(connected).toBe(1) })
  116. expect(api.openGenerationCount).toBe(1)
  117. expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error))
  118. } finally {
  119. controller.stop()
  120. errorSpy.mockRestore()
  121. }
  122. })
  123. it('holds onConnected until the incremental source is ready after describe succeeds', async () => {
  124. const api = new FakeApiClient()
  125. api.holdGenerationReady = true
  126. let connected = 0
  127. const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
  128. controller.start()
  129. try {
  130. await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
  131. await new Promise(resolve => setTimeout(resolve, 30))
  132. expect(connected).toBe(0) // describe alone must not announce
  133. api.releaseGenerationReady()
  134. await vi.waitFor(() => { expect(connected).toBe(1) })
  135. } finally {
  136. controller.stop()
  137. }
  138. })
  139. it('rejects a generation whose source ends during readiness and retries', async () => {
  140. const api = new FakeApiClient()
  141. const firstDescribe = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
  142. let describeCalls = 0
  143. api.onDescribe = () => {
  144. describeCalls++
  145. return describeCalls === 1
  146. ? firstDescribe.promise
  147. : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
  148. }
  149. const states: ConnectionState[] = []
  150. let connected = 0
  151. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  152. const controller = new ConnectionController(api, api.generation, {
  153. onConnected: () => { connected++ },
  154. onStateChange: state => states.push(state),
  155. }, FAST)
  156. controller.start()
  157. try {
  158. await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) })
  159. api.endStreams()
  160. firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
  161. await vi.waitFor(() => { expect(describeCalls).toBe(2) })
  162. await vi.waitFor(() => { expect(connected).toBe(1) })
  163. expect(states).toEqual(['reconnecting', 'connected'])
  164. } finally {
  165. controller.stop()
  166. warnSpy.mockRestore()
  167. }
  168. })
  169. it.each([
  170. { label: 'ends normally', fail: () => Promise.resolve() },
  171. {
  172. label: 'rejects with a non-Error reason',
  173. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario.
  174. fail: () => Promise.reject('fixture offline'),
  175. },
  176. ])('retries when the generation source $label before reporting ready', async ({ fail }) => {
  177. const api = new FakeApiClient()
  178. let sourceCalls = 0
  179. let connected = 0
  180. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  181. const controller = new ConnectionController(api, (signal, ready) => {
  182. sourceCalls++
  183. if (sourceCalls === 1) return fail()
  184. ready()
  185. return new Promise<void>((resolve) => {
  186. signal.addEventListener('abort', () => { resolve() }, { once: true })
  187. })
  188. }, { onConnected: () => { connected++ } }, FAST)
  189. controller.start()
  190. try {
  191. await vi.waitFor(() => { expect(sourceCalls).toBe(2) })
  192. await vi.waitFor(() => { expect(connected).toBe(1) })
  193. } finally {
  194. controller.stop()
  195. warnSpy.mockRestore()
  196. }
  197. })
  198. it('rejects and retries a generation whose source never reports ready', async () => {
  199. const api = new FakeApiClient()
  200. api.suppressGenerationReady = true
  201. let connected = 0
  202. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  203. const controller = new ConnectionController(
  204. api,
  205. api.generation,
  206. { onConnected: () => { connected++ } },
  207. { ...FAST, generationReadyTimeoutMs: 20 },
  208. )
  209. controller.start()
  210. try {
  211. await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) })
  212. expect(connected).toBe(0)
  213. } finally {
  214. controller.stop()
  215. warnSpy.mockRestore()
  216. }
  217. })
  218. it('emits deduplicated connected/reconnecting state transitions', async () => {
  219. const api = new FakeApiClient()
  220. const states: ConnectionState[] = []
  221. let connected = 0
  222. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  223. const controller = new ConnectionController(api, api.generation, {
  224. onConnected: () => { connected++ },
  225. onStateChange: state => states.push(state),
  226. }, FAST)
  227. controller.start()
  228. try {
  229. await vi.waitFor(() => { expect(connected).toBe(1) })
  230. expect(states).toEqual(['connected'])
  231. api.failStreams(new Error('torn'))
  232. await vi.waitFor(() => { expect(connected).toBe(2) })
  233. expect(states).toEqual(['connected', 'reconnecting', 'connected'])
  234. } finally {
  235. controller.stop()
  236. warnSpy.mockRestore()
  237. }
  238. })
  239. it('does not announce a generation stopped synchronously by its connected state sink', async () => {
  240. const api = new FakeApiClient()
  241. const states: ConnectionState[] = []
  242. let connected = 0
  243. const controller = new ConnectionController(api, api.generation, {
  244. onConnected: () => { connected++ },
  245. onStateChange: (state) => {
  246. states.push(state)
  247. if (state === 'connected') controller.stop()
  248. },
  249. }, FAST)
  250. controller.start()
  251. await vi.waitFor(() => { expect(states).toEqual(['connected']) })
  252. await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) })
  253. expect(connected).toBe(0)
  254. })
  255. it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
  256. const api = new FakeApiClient()
  257. const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
  258. let describeCalls = 0
  259. api.onDescribe = () => {
  260. describeCalls++
  261. return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
  262. }
  263. const states: ConnectionState[] = []
  264. let connected = 0
  265. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  266. const controller = new ConnectionController(api, api.generation, {
  267. onConnected: () => { connected++ },
  268. onStateChange: state => states.push(state),
  269. }, FAST)
  270. controller.start()
  271. try {
  272. await vi.waitFor(() => { expect(describeCalls).toBe(3) })
  273. gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
  274. await vi.waitFor(() => { expect(connected).toBe(1) })
  275. expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
  276. } finally {
  277. controller.stop()
  278. warnSpy.mockRestore()
  279. }
  280. })
  281. it('runs with no sinks at all (every callback slot optional)', async () => {
  282. const api = new FakeApiClient()
  283. const controller = new ConnectionController(api, api.generation, {}, FAST)
  284. controller.start()
  285. try {
  286. await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
  287. await new Promise(resolve => setTimeout(resolve, 20))
  288. } finally {
  289. controller.stop()
  290. }
  291. })
  292. it('start() is idempotent (one loop, one stream set)', async () => {
  293. const api = new FakeApiClient()
  294. let connected = 0
  295. const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
  296. controller.start()
  297. controller.start()
  298. try {
  299. await vi.waitFor(() => { expect(connected).toBe(1) })
  300. expect(api.openGenerationCount).toBe(1)
  301. expect(api.callsOf('host.describe')).toHaveLength(1)
  302. } finally {
  303. controller.stop()
  304. }
  305. })
  306. })