connection.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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. let connected = 0
  23. const controller = new ConnectionController(api, {
  24. onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
  25. onConnected: () => { connected++ },
  26. }, FAST)
  27. controller.start()
  28. try {
  29. await vi.waitFor(() => { expect(connected).toBe(1) })
  30. api.pushMux(subscribedFrame())
  31. await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
  32. expect(api.callsOf('host.describe')).toHaveLength(1)
  33. } finally {
  34. controller.stop()
  35. }
  36. })
  37. it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => {
  38. const api = new FakeApiClient()
  39. let connected = 0
  40. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  41. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
  42. controller.start()
  43. try {
  44. await vi.waitFor(() => { expect(connected).toBe(1) })
  45. api.failStreams(new Error('stream torn'))
  46. await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff
  47. expect(api.openMuxCount).toBe(1) // the dead generation's stream is gone, exactly one live
  48. } finally {
  49. controller.stop()
  50. warnSpy.mockRestore()
  51. }
  52. // stop() aborts the live generation (streams tear down) and no reconnect follows.
  53. await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
  54. await new Promise(resolve => setTimeout(resolve, 40))
  55. expect(api.openMuxCount).toBe(0)
  56. })
  57. it('treats describe failure as generation failure and retries', async () => {
  58. const api = new FakeApiClient()
  59. const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
  60. let describeCalls = 0
  61. api.onDescribe = () => {
  62. describeCalls++
  63. return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise
  64. }
  65. let connected = 0
  66. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  67. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
  68. controller.start()
  69. try {
  70. await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
  71. expect(connected).toBe(0) // never announced during the failed generation
  72. gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
  73. await vi.waitFor(() => { expect(connected).toBe(1) })
  74. } finally {
  75. controller.stop()
  76. warnSpy.mockRestore()
  77. }
  78. })
  79. it('converges stream/error frames into reconnect instead of dispatching them', async () => {
  80. const api = new FakeApiClient()
  81. const muxSeen: string[] = []
  82. let connected = 0
  83. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  84. const controller = new ConnectionController(api, {
  85. onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
  86. onConnected: () => { connected++ },
  87. }, FAST)
  88. controller.start()
  89. try {
  90. await vi.waitFor(() => { expect(connected).toBe(1) })
  91. api.pushMux({ type: 'stream/error', error: { code: 'internal', message: 'impl broke', details: {} } })
  92. await vi.waitFor(() => { expect(connected).toBe(2) }) // treated as loss → reconnect
  93. expect(muxSeen).toEqual([]) // never forwarded to the business sink
  94. } finally {
  95. controller.stop()
  96. warnSpy.mockRestore()
  97. }
  98. })
  99. it('isolates sink exceptions from the pump', async () => {
  100. const api = new FakeApiClient()
  101. const seen: string[] = []
  102. let connected = 0
  103. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  104. const controller = new ConnectionController(api, {
  105. onMuxEnvelope: (envelope) => {
  106. seen.push(envelope.payload.type)
  107. throw new Error('business layer bug')
  108. },
  109. onConnected: () => { connected++ },
  110. }, FAST)
  111. controller.start()
  112. try {
  113. await vi.waitFor(() => { expect(connected).toBe(1) })
  114. api.pushMux(subscribedFrame(1))
  115. api.pushMux(subscribedFrame(2))
  116. await vi.waitFor(() => { expect(seen).toHaveLength(2) }) // second frame still pumped
  117. expect(connected).toBe(1) // no reconnect triggered by the sink throw
  118. } finally {
  119. controller.stop()
  120. errorSpy.mockRestore()
  121. }
  122. })
  123. it('holds onConnected until both streams establish even after describe succeeds', async () => {
  124. const api = new FakeApiClient()
  125. api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand
  126. let connected = 0
  127. const controller = new ConnectionController(api, { 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.releaseStreamOpens()
  134. await vi.waitFor(() => { expect(connected).toBe(1) })
  135. } finally {
  136. controller.stop()
  137. }
  138. })
  139. it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
  140. const api = new FakeApiClient()
  141. api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
  142. let connected = 0
  143. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 })
  144. controller.start()
  145. try {
  146. await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged
  147. } finally {
  148. controller.stop()
  149. }
  150. })
  151. it('emits deduplicated connected/reconnecting state transitions', async () => {
  152. const api = new FakeApiClient()
  153. const states: ConnectionState[] = []
  154. let connected = 0
  155. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  156. const controller = new ConnectionController(api, {
  157. onConnected: () => { connected++ },
  158. onStateChange: state => states.push(state),
  159. }, FAST)
  160. controller.start()
  161. try {
  162. await vi.waitFor(() => { expect(connected).toBe(1) })
  163. expect(states).toEqual(['connected'])
  164. api.failStreams(new Error('torn'))
  165. await vi.waitFor(() => { expect(connected).toBe(2) })
  166. expect(states).toEqual(['connected', 'reconnecting', 'connected'])
  167. } finally {
  168. controller.stop()
  169. warnSpy.mockRestore()
  170. }
  171. })
  172. it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
  173. const api = new FakeApiClient()
  174. const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
  175. let describeCalls = 0
  176. api.onDescribe = () => {
  177. describeCalls++
  178. return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
  179. }
  180. const states: ConnectionState[] = []
  181. let connected = 0
  182. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  183. const controller = new ConnectionController(api, {
  184. onConnected: () => { connected++ },
  185. onStateChange: state => states.push(state),
  186. }, FAST)
  187. controller.start()
  188. try {
  189. await vi.waitFor(() => { expect(describeCalls).toBe(3) })
  190. gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
  191. await vi.waitFor(() => { expect(connected).toBe(1) })
  192. expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
  193. } finally {
  194. controller.stop()
  195. warnSpy.mockRestore()
  196. }
  197. })
  198. it('runs with no sinks at all (every callback slot optional)', async () => {
  199. const api = new FakeApiClient()
  200. const controller = new ConnectionController(api, {}, FAST)
  201. controller.start()
  202. try {
  203. await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
  204. api.pushMux(subscribedFrame()) // pumped with sink undefined: dropped silently
  205. await new Promise(resolve => setTimeout(resolve, 20))
  206. } finally {
  207. controller.stop()
  208. }
  209. })
  210. it('start() is idempotent (one loop, one stream set)', async () => {
  211. const api = new FakeApiClient()
  212. let connected = 0
  213. const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
  214. controller.start()
  215. controller.start()
  216. try {
  217. await vi.waitFor(() => { expect(connected).toBe(1) })
  218. expect(api.openMuxCount).toBe(1)
  219. expect(api.callsOf('host.describe')).toHaveLength(1)
  220. } finally {
  221. controller.stop()
  222. }
  223. })
  224. })