connection.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. /**
  2. * ConnectionController: stream pumping into sinks, the strict readiness
  3. * handshake (describe + both streams' onOpen, timeout-guarded), 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 { SessionId } from '../src/client/api.ts'
  10. import type { ConnectionState } from '../src/client/connection.ts'
  11. import { ConnectionController } from '../src/client/connection.ts'
  12. import { FakeApiClient, deferred, ok } from './fake-api.ts'
  13. const SID = 'fk-c1' as SessionId
  14. const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }
  15. function subscribedFrame(lastSeq = 0) {
  16. return { type: 'session/subscribed', sessionId: SID, lastSeq } as const
  17. }
  18. describe('connection lifecycle', () => {
  19. it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
  20. const api = new FakeApiClient()
  21. const muxSeen: string[] = []
  22. const descriptions: boolean[] = []
  23. let connected = 0
  24. const controller = new ConnectionController(api, {
  25. onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
  26. onConnected: (description) => {
  27. connected++
  28. descriptions.push(description.canOpenPath)
  29. },
  30. }, FAST)
  31. controller.start()
  32. try {
  33. await vi.waitFor(() => { expect(connected).toBe(1) })
  34. api.pushMux(subscribedFrame())
  35. await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
  36. expect(api.callsOf('host.describe')).toHaveLength(1)
  37. expect(descriptions).toEqual([true])
  38. } finally {
  39. controller.stop()
  40. }
  41. })
  42. it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => {
  43. const api = new FakeApiClient()
  44. let connected = 0
  45. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  46. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
  47. controller.start()
  48. try {
  49. await vi.waitFor(() => { expect(connected).toBe(1) })
  50. api.failStreams(new Error('stream torn'))
  51. await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff
  52. expect(api.openMuxCount).toBe(1) // the dead generation's stream is gone, exactly one live
  53. } finally {
  54. controller.stop()
  55. warnSpy.mockRestore()
  56. }
  57. // stop() aborts the live generation (streams tear down) and no reconnect follows.
  58. await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
  59. await new Promise(resolve => setTimeout(resolve, 40))
  60. expect(api.openMuxCount).toBe(0)
  61. })
  62. it('treats describe failure as generation failure and retries', async () => {
  63. const api = new FakeApiClient()
  64. const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
  65. let describeCalls = 0
  66. api.onDescribe = () => {
  67. describeCalls++
  68. return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise
  69. }
  70. let connected = 0
  71. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  72. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
  73. controller.start()
  74. try {
  75. await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
  76. expect(connected).toBe(0) // never announced during the failed generation
  77. gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
  78. await vi.waitFor(() => { expect(connected).toBe(1) })
  79. } finally {
  80. controller.stop()
  81. warnSpy.mockRestore()
  82. }
  83. })
  84. it('treats a host.describe business error as generation failure', async () => {
  85. const api = new FakeApiClient()
  86. let describeCalls = 0
  87. api.onDescribe = () => {
  88. describeCalls += 1
  89. if (describeCalls === 1) {
  90. return Promise.resolve({
  91. rpcId: 'bad-describe' as never,
  92. result: {
  93. ok: false as const,
  94. error: { code: 'internal' as const, message: 'not ready', details: {} },
  95. },
  96. })
  97. }
  98. return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
  99. }
  100. let connected = 0
  101. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  102. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
  103. controller.start()
  104. try {
  105. await vi.waitFor(() => { expect(describeCalls).toBe(2) })
  106. await vi.waitFor(() => { expect(connected).toBe(1) })
  107. } finally {
  108. controller.stop()
  109. warnSpy.mockRestore()
  110. }
  111. })
  112. it('converges stream/error frames into reconnect instead of dispatching them', async () => {
  113. const api = new FakeApiClient()
  114. const muxSeen: string[] = []
  115. let connected = 0
  116. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  117. const controller = new ConnectionController(api, {
  118. onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
  119. onConnected: () => { connected++ },
  120. }, FAST)
  121. controller.start()
  122. try {
  123. await vi.waitFor(() => { expect(connected).toBe(1) })
  124. api.pushMux({ type: 'stream/error', error: { code: 'internal', message: 'impl broke', details: {} } })
  125. await vi.waitFor(() => { expect(connected).toBe(2) }) // treated as loss → reconnect
  126. expect(muxSeen).toEqual([]) // never forwarded to the business sink
  127. } finally {
  128. controller.stop()
  129. warnSpy.mockRestore()
  130. }
  131. })
  132. it('isolates sink exceptions from the pump', async () => {
  133. const api = new FakeApiClient()
  134. const seen: string[] = []
  135. let connected = 0
  136. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  137. const controller = new ConnectionController(api, {
  138. onMuxEnvelope: (envelope) => {
  139. seen.push(envelope.payload.type)
  140. throw new Error('business layer bug')
  141. },
  142. onConnected: () => { connected++ },
  143. }, FAST)
  144. controller.start()
  145. try {
  146. await vi.waitFor(() => { expect(connected).toBe(1) })
  147. api.pushMux(subscribedFrame(1))
  148. api.pushMux(subscribedFrame(2))
  149. await vi.waitFor(() => { expect(seen).toHaveLength(2) }) // second frame still pumped
  150. expect(connected).toBe(1) // no reconnect triggered by the sink throw
  151. } finally {
  152. controller.stop()
  153. errorSpy.mockRestore()
  154. }
  155. })
  156. it('holds onConnected until both streams establish even after describe succeeds', async () => {
  157. const api = new FakeApiClient()
  158. api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand
  159. let connected = 0
  160. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
  161. controller.start()
  162. try {
  163. await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
  164. await new Promise(resolve => setTimeout(resolve, 30))
  165. expect(connected).toBe(0) // describe alone must not announce
  166. api.releaseStreamOpens()
  167. await vi.waitFor(() => { expect(connected).toBe(1) })
  168. } finally {
  169. controller.stop()
  170. }
  171. })
  172. it('rejects a generation whose streams end during readiness and retries', async () => {
  173. const api = new FakeApiClient()
  174. const firstDescribe = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
  175. let describeCalls = 0
  176. api.onDescribe = () => {
  177. describeCalls++
  178. return describeCalls === 1
  179. ? firstDescribe.promise
  180. : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
  181. }
  182. const states: ConnectionState[] = []
  183. let connected = 0
  184. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  185. const controller = new ConnectionController(api, {
  186. onConnected: () => { connected++ },
  187. onStateChange: state => states.push(state),
  188. }, FAST)
  189. controller.start()
  190. try {
  191. await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) })
  192. api.endStreams()
  193. firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
  194. await vi.waitFor(() => { expect(describeCalls).toBe(2) })
  195. await vi.waitFor(() => { expect(connected).toBe(1) })
  196. expect(states).toEqual(['reconnecting', 'connected'])
  197. } finally {
  198. controller.stop()
  199. warnSpy.mockRestore()
  200. }
  201. })
  202. it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
  203. const api = new FakeApiClient()
  204. api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
  205. let connected = 0
  206. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 })
  207. controller.start()
  208. try {
  209. await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged
  210. } finally {
  211. controller.stop()
  212. }
  213. })
  214. it('emits deduplicated connected/reconnecting state transitions', async () => {
  215. const api = new FakeApiClient()
  216. const states: ConnectionState[] = []
  217. let connected = 0
  218. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  219. const controller = new ConnectionController(api, {
  220. onConnected: () => { connected++ },
  221. onStateChange: state => states.push(state),
  222. }, FAST)
  223. controller.start()
  224. try {
  225. await vi.waitFor(() => { expect(connected).toBe(1) })
  226. expect(states).toEqual(['connected'])
  227. api.failStreams(new Error('torn'))
  228. await vi.waitFor(() => { expect(connected).toBe(2) })
  229. expect(states).toEqual(['connected', 'reconnecting', 'connected'])
  230. } finally {
  231. controller.stop()
  232. warnSpy.mockRestore()
  233. }
  234. })
  235. it('does not announce a generation stopped synchronously by its connected state sink', async () => {
  236. const api = new FakeApiClient()
  237. const states: ConnectionState[] = []
  238. let connected = 0
  239. const controller = new ConnectionController(api, {
  240. onConnected: () => { connected++ },
  241. onStateChange: (state) => {
  242. states.push(state)
  243. if (state === 'connected') controller.stop()
  244. },
  245. }, FAST)
  246. controller.start()
  247. await vi.waitFor(() => { expect(states).toEqual(['connected']) })
  248. await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
  249. expect(connected).toBe(0)
  250. })
  251. it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
  252. const api = new FakeApiClient()
  253. const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
  254. let describeCalls = 0
  255. api.onDescribe = () => {
  256. describeCalls++
  257. return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
  258. }
  259. const states: ConnectionState[] = []
  260. let connected = 0
  261. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  262. const controller = new ConnectionController(api, {
  263. onConnected: () => { connected++ },
  264. onStateChange: state => states.push(state),
  265. }, FAST)
  266. controller.start()
  267. try {
  268. await vi.waitFor(() => { expect(describeCalls).toBe(3) })
  269. gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
  270. await vi.waitFor(() => { expect(connected).toBe(1) })
  271. expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
  272. } finally {
  273. controller.stop()
  274. warnSpy.mockRestore()
  275. }
  276. })
  277. it('runs with no sinks at all (every callback slot optional)', async () => {
  278. const api = new FakeApiClient()
  279. const controller = new ConnectionController(api, {}, FAST)
  280. controller.start()
  281. try {
  282. await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
  283. api.pushMux(subscribedFrame()) // pumped with sink undefined: dropped silently
  284. await new Promise(resolve => setTimeout(resolve, 20))
  285. } finally {
  286. controller.stop()
  287. }
  288. })
  289. it('start() is idempotent (one loop, one stream set)', async () => {
  290. const api = new FakeApiClient()
  291. let connected = 0
  292. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
  293. controller.start()
  294. controller.start()
  295. try {
  296. await vi.waitFor(() => { expect(connected).toBe(1) })
  297. expect(api.openMuxCount).toBe(1)
  298. expect(api.callsOf('host.describe')).toHaveLength(1)
  299. } finally {
  300. controller.stop()
  301. }
  302. })
  303. })