connection.client.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800
  1. /** Connection generation readiness, loss, retry, and sink isolation. */
  2. import { describe, expect, it, vi } from 'vitest'
  3. import type { ConnectionGenerationSource, ConnectionState } from '../src/client/connection.ts'
  4. import { ConnectionController } from '../src/client/connection.ts'
  5. import { FakeGenerationSource } from './fake-generation.client.ts'
  6. const FAST = { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 }
  7. describe('connection lifecycle', () => {
  8. it('announces connected with the Host facts from generation readiness', async () => {
  9. const source = new FakeGenerationSource()
  10. const homes: string[] = []
  11. const controller = new ConnectionController(source.source, {
  12. onConnected: (host) => { homes.push(host.home) },
  13. }, FAST)
  14. controller.start()
  15. try {
  16. await vi.waitFor(() => { expect(homes).toEqual(['/h']) })
  17. } finally {
  18. controller.stop()
  19. }
  20. })
  21. it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => {
  22. const source = new FakeGenerationSource()
  23. let connected = 0
  24. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  25. const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
  26. controller.start()
  27. try {
  28. await vi.waitFor(() => { expect(connected).toBe(1) })
  29. source.fail(new Error('stream torn'))
  30. await vi.waitFor(() => { expect(connected).toBe(2) })
  31. expect(source.activeCount).toBe(1)
  32. } finally {
  33. controller.stop()
  34. warnSpy.mockRestore()
  35. }
  36. await vi.waitFor(() => { expect(source.activeCount).toBe(0) })
  37. await new Promise(resolve => setTimeout(resolve, 40))
  38. expect(source.activeCount).toBe(0)
  39. })
  40. it('keeps retrying at the jittered cap and recovers after a prolonged outage', async () => {
  41. vi.useFakeTimers()
  42. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
  43. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  44. const reconnectRequested = vi.fn()
  45. let calls = 0
  46. let available = false
  47. const states: ConnectionState[] = []
  48. const source: ConnectionGenerationSource = (signal, ready) => {
  49. calls++
  50. if (!available) return Promise.reject(new Error('offline'))
  51. ready({ home: '/h' })
  52. return new Promise<void>((resolve) => {
  53. signal.addEventListener('abort', () => { resolve() }, { once: true })
  54. })
  55. }
  56. const controller = new ConnectionController(source, {
  57. onReconnectRequested: reconnectRequested,
  58. onStateChange: state => states.push(state),
  59. })
  60. controller.start()
  61. try {
  62. await vi.advanceTimersByTimeAsync(0)
  63. expect(calls).toBe(1)
  64. expect(states).toEqual(['connecting'])
  65. for (const [attempt, delay] of [250, 500, 1_000, 2_000, 4_000, 5_000].entries()) {
  66. await vi.advanceTimersByTimeAsync(delay)
  67. expect(calls).toBe(attempt + 2)
  68. }
  69. expect(reconnectRequested).toHaveBeenCalledTimes(6)
  70. expect(warnSpy).toHaveBeenCalledTimes(6)
  71. expect(warnSpy).toHaveBeenLastCalledWith('[connection] connection lost, retry #6')
  72. expect(states).toEqual(['connecting'])
  73. await vi.advanceTimersByTimeAsync(60_000)
  74. expect(calls).toBe(19)
  75. available = true
  76. await vi.advanceTimersByTimeAsync(5_000)
  77. expect(calls).toBe(20)
  78. expect(states).toEqual(['connecting', 'connected'])
  79. expect(reconnectRequested).toHaveBeenCalledTimes(19)
  80. } finally {
  81. controller.stop()
  82. randomSpy.mockRestore()
  83. warnSpy.mockRestore()
  84. vi.useRealTimers()
  85. }
  86. })
  87. it('continues retrying at a fixed cap when the growth factor is one', async () => {
  88. vi.useFakeTimers()
  89. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
  90. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  91. const states: ConnectionState[] = []
  92. let calls = 0
  93. const controller = new ConnectionController(() => {
  94. calls++
  95. return Promise.reject(new Error('offline'))
  96. }, {
  97. onStateChange: state => states.push(state),
  98. }, {
  99. backoffBaseMs: 10,
  100. backoffFactor: 1,
  101. backoffMaxMs: 80,
  102. generationReadyTimeoutMs: 500,
  103. })
  104. controller.start()
  105. try {
  106. await vi.advanceTimersByTimeAsync(5)
  107. expect(calls).toBe(2)
  108. expect(states).toEqual(['connecting'])
  109. await vi.advanceTimersByTimeAsync(1_000)
  110. expect(calls).toBe(202)
  111. } finally {
  112. controller.stop()
  113. randomSpy.mockRestore()
  114. warnSpy.mockRestore()
  115. vi.useRealTimers()
  116. }
  117. })
  118. it('interrupts the retry delay when a reconnect is requested', async () => {
  119. vi.useFakeTimers()
  120. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  121. const reconnectRequested = vi.fn()
  122. let calls = 0
  123. const source: ConnectionGenerationSource = (signal, ready) => {
  124. calls++
  125. if (calls === 1) return Promise.reject(new Error('offline'))
  126. ready({ home: '/h' })
  127. return new Promise<void>((resolve) => {
  128. signal.addEventListener('abort', () => { resolve() }, { once: true })
  129. })
  130. }
  131. const controller = new ConnectionController(source, { onReconnectRequested: reconnectRequested })
  132. controller.start()
  133. try {
  134. await vi.advanceTimersByTimeAsync(0)
  135. expect(calls).toBe(1)
  136. controller.reconnect()
  137. await vi.advanceTimersByTimeAsync(0)
  138. expect(calls).toBe(2)
  139. expect(reconnectRequested).toHaveBeenCalledOnce()
  140. } finally {
  141. controller.stop()
  142. controller.reconnect()
  143. warnSpy.mockRestore()
  144. vi.useRealTimers()
  145. }
  146. })
  147. it('pauses retries while offline and restarts the base delay after each recovery', async () => {
  148. vi.useFakeTimers()
  149. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
  150. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  151. const states: ConnectionState[] = []
  152. let calls = 0
  153. let active = 0
  154. let maxActive = 0
  155. const source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
  156. calls++
  157. active++
  158. maxActive = Math.max(maxActive, active)
  159. ready({ home: '/h' })
  160. signal.addEventListener('abort', () => {
  161. active--
  162. resolve()
  163. }, { once: true })
  164. })
  165. const controller = new ConnectionController(source, {
  166. onStateChange: state => states.push(state),
  167. })
  168. controller.start()
  169. try {
  170. await vi.advanceTimersByTimeAsync(0)
  171. expect(calls).toBe(1)
  172. expect(states).toEqual(['connected'])
  173. controller.setNetworkAvailable(false)
  174. controller.setNetworkAvailable(false)
  175. expect(states.at(-1)).toBe('disconnected')
  176. await vi.advanceTimersByTimeAsync(60_000)
  177. expect(calls).toBe(1)
  178. expect(active).toBe(0)
  179. controller.setNetworkAvailable(true)
  180. controller.setNetworkAvailable(true)
  181. expect(states.at(-1)).toBe('connecting')
  182. await vi.advanceTimersByTimeAsync(125)
  183. controller.setNetworkAvailable(false)
  184. await vi.advanceTimersByTimeAsync(60_000)
  185. expect(calls).toBe(1)
  186. controller.setNetworkAvailable(true)
  187. await vi.advanceTimersByTimeAsync(249)
  188. expect(calls).toBe(1)
  189. await vi.advanceTimersByTimeAsync(1)
  190. expect(calls).toBe(2)
  191. expect(active).toBe(1)
  192. expect(maxActive).toBe(1)
  193. expect(states).toEqual([
  194. 'connected',
  195. 'disconnected',
  196. 'connecting',
  197. 'disconnected',
  198. 'connecting',
  199. 'connected',
  200. ])
  201. expect(warnSpy).toHaveBeenCalledOnce()
  202. expect(warnSpy).toHaveBeenCalledWith('[connection] connection lost, retry #1')
  203. } finally {
  204. controller.stop()
  205. randomSpy.mockRestore()
  206. warnSpy.mockRestore()
  207. vi.useRealTimers()
  208. }
  209. })
  210. it('stops an offline wait when its state sink stops the controller synchronously', async () => {
  211. const source = vi.fn<ConnectionGenerationSource>(() => Promise.resolve())
  212. const controller = new ConnectionController(source, {
  213. onStateChange: () => { controller.stop() },
  214. })
  215. controller.setNetworkAvailable(false)
  216. controller.start()
  217. await Promise.resolve()
  218. expect(source).not.toHaveBeenCalled()
  219. })
  220. it('allows one manual attempt while offline without starting automatic retries', async () => {
  221. vi.useFakeTimers()
  222. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  223. const states: ConnectionState[] = []
  224. let calls = 0
  225. const controller = new ConnectionController(() => {
  226. calls++
  227. return Promise.reject(new Error('offline'))
  228. }, {
  229. onStateChange: state => states.push(state),
  230. })
  231. controller.setNetworkAvailable(false)
  232. controller.start()
  233. try {
  234. await vi.advanceTimersByTimeAsync(0)
  235. expect(states).toEqual(['disconnected'])
  236. expect(calls).toBe(0)
  237. controller.reconnect()
  238. expect(states.at(-1)).toBe('connecting')
  239. await vi.advanceTimersByTimeAsync(0)
  240. expect(calls).toBe(1)
  241. expect(states.at(-1)).toBe('disconnected')
  242. await vi.advanceTimersByTimeAsync(60_000)
  243. expect(calls).toBe(1)
  244. } finally {
  245. controller.stop()
  246. warnSpy.mockRestore()
  247. vi.useRealTimers()
  248. }
  249. })
  250. it('does not delay a reconnect requested synchronously from the connecting state sink', async () => {
  251. vi.useFakeTimers()
  252. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
  253. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  254. let calls = 0
  255. let restart = true
  256. const controller = new ConnectionController(() => {
  257. calls++
  258. return Promise.reject(new Error('offline'))
  259. }, {
  260. onStateChange: (state) => {
  261. if (state !== 'connecting' || !restart) return
  262. restart = false
  263. controller.reconnect()
  264. },
  265. }, {
  266. backoffBaseMs: 10,
  267. backoffFactor: 2,
  268. backoffMaxMs: 10,
  269. generationReadyTimeoutMs: 500,
  270. })
  271. controller.start()
  272. try {
  273. await vi.advanceTimersByTimeAsync(0)
  274. expect(calls).toBe(2)
  275. expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([
  276. '[connection] connection lost, retry #1',
  277. ])
  278. } finally {
  279. controller.stop()
  280. randomSpy.mockRestore()
  281. warnSpy.mockRestore()
  282. vi.useRealTimers()
  283. }
  284. })
  285. it.each([
  286. {
  287. label: 'manual reconnect',
  288. stopState: 'connecting' as const,
  289. interrupt: (controller: ConnectionController) => { controller.reconnect() },
  290. },
  291. {
  292. label: 'browser going offline',
  293. stopState: 'disconnected' as const,
  294. interrupt: (controller: ConnectionController) => { controller.setNetworkAvailable(false) },
  295. },
  296. ])('honors a synchronous stop from the $label state sink', async ({ stopState, interrupt }) => {
  297. const source = new FakeGenerationSource()
  298. const controller = new ConnectionController(source.source, {
  299. onStateChange: (state) => {
  300. if (state === stopState) controller.stop()
  301. },
  302. }, FAST)
  303. controller.start()
  304. await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
  305. interrupt(controller)
  306. await vi.waitFor(() => { expect(source.activeCount).toBe(0) })
  307. })
  308. it('stops when the physical-reconnect sink disposes the controller', async () => {
  309. vi.useFakeTimers()
  310. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
  311. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  312. let calls = 0
  313. const reconnectRequested = vi.fn()
  314. const controller = new ConnectionController(() => {
  315. calls++
  316. return Promise.reject(new Error('offline'))
  317. }, {
  318. onReconnectRequested: () => {
  319. reconnectRequested()
  320. controller.stop()
  321. },
  322. }, FAST)
  323. controller.start()
  324. try {
  325. await vi.advanceTimersByTimeAsync(5)
  326. expect(calls).toBe(1)
  327. expect(reconnectRequested).toHaveBeenCalledOnce()
  328. } finally {
  329. controller.stop()
  330. randomSpy.mockRestore()
  331. warnSpy.mockRestore()
  332. vi.useRealTimers()
  333. }
  334. })
  335. it('stops before opening a retry when the connecting state sink disposes the controller', async () => {
  336. vi.useFakeTimers()
  337. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  338. let calls = 0
  339. const controller = new ConnectionController(() => {
  340. calls++
  341. return Promise.reject(new Error('offline'))
  342. }, {
  343. onStateChange: (state) => {
  344. if (state === 'connecting') controller.stop()
  345. },
  346. })
  347. controller.start()
  348. try {
  349. await vi.advanceTimersByTimeAsync(60_000)
  350. expect(calls).toBe(1)
  351. expect(warnSpy).not.toHaveBeenCalled()
  352. } finally {
  353. controller.stop()
  354. warnSpy.mockRestore()
  355. vi.useRealTimers()
  356. }
  357. })
  358. it('restarts an active retry immediately and resets its attempt number', async () => {
  359. vi.useFakeTimers()
  360. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
  361. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  362. const reconnectRequested = vi.fn()
  363. const states: ConnectionState[] = []
  364. let calls = 0
  365. const source: ConnectionGenerationSource = (signal) => {
  366. calls++
  367. if (calls <= 2) return Promise.reject(new Error('offline'))
  368. return new Promise<void>((resolve) => {
  369. signal.addEventListener('abort', () => { resolve() }, { once: true })
  370. })
  371. }
  372. const controller = new ConnectionController(source, {
  373. onReconnectRequested: reconnectRequested,
  374. onStateChange: state => states.push(state),
  375. }, FAST)
  376. controller.start()
  377. try {
  378. await vi.advanceTimersByTimeAsync(20)
  379. expect(calls).toBe(3)
  380. expect(states.at(-1)).toBe('connecting')
  381. controller.reconnect()
  382. await vi.advanceTimersByTimeAsync(0)
  383. expect(calls).toBe(4)
  384. expect(states.at(-1)).toBe('connecting')
  385. expect(reconnectRequested).toHaveBeenCalledTimes(3)
  386. expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([
  387. '[connection] connection lost, retry #1',
  388. '[connection] connection lost, retry #2',
  389. '[connection] connection lost, retry #1',
  390. ])
  391. } finally {
  392. controller.stop()
  393. randomSpy.mockRestore()
  394. warnSpy.mockRestore()
  395. vi.useRealTimers()
  396. }
  397. })
  398. it('stops while an automatic retry delay is pending', async () => {
  399. vi.useFakeTimers()
  400. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  401. let calls = 0
  402. const controller = new ConnectionController(() => {
  403. calls++
  404. return Promise.reject(new Error('offline'))
  405. })
  406. controller.start()
  407. try {
  408. await vi.advanceTimersByTimeAsync(0)
  409. expect(calls).toBe(1)
  410. controller.stop()
  411. await vi.advanceTimersByTimeAsync(2_000)
  412. expect(calls).toBe(1)
  413. } finally {
  414. controller.stop()
  415. warnSpy.mockRestore()
  416. vi.useRealTimers()
  417. }
  418. })
  419. it('replaces an active generation immediately when reconnect is requested', async () => {
  420. const source = new FakeGenerationSource()
  421. const reconnectRequested = vi.fn()
  422. let connected = 0
  423. const controller = new ConnectionController(source.source, {
  424. onConnected: () => { connected++ },
  425. onReconnectRequested: reconnectRequested,
  426. }, { backoffBaseMs: 60_000, backoffFactor: 2, backoffMaxMs: 120_000, generationReadyTimeoutMs: 500 })
  427. controller.start()
  428. try {
  429. await vi.waitFor(() => { expect(connected).toBe(1) })
  430. controller.reconnect()
  431. await vi.waitFor(() => { expect(connected).toBe(2) })
  432. expect(reconnectRequested).toHaveBeenCalledOnce()
  433. expect(source.activeCount).toBe(1)
  434. } finally {
  435. controller.stop()
  436. }
  437. })
  438. it('isolates a connected sink exception from the generation', async () => {
  439. const source = new FakeGenerationSource()
  440. let connected = 0
  441. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  442. const controller = new ConnectionController(source.source, {
  443. onConnected: () => {
  444. connected++
  445. throw new Error('business layer bug')
  446. },
  447. }, FAST)
  448. controller.start()
  449. try {
  450. await vi.waitFor(() => { expect(connected).toBe(1) })
  451. expect(source.activeCount).toBe(1)
  452. expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error))
  453. } finally {
  454. controller.stop()
  455. errorSpy.mockRestore()
  456. }
  457. })
  458. it('holds onConnected until the incremental source reports ready', async () => {
  459. const source = new FakeGenerationSource()
  460. source.holdReady = true
  461. let connected = 0
  462. const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
  463. controller.start()
  464. try {
  465. await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
  466. await new Promise(resolve => setTimeout(resolve, 30))
  467. expect(connected).toBe(0)
  468. source.releaseReady()
  469. await vi.waitFor(() => { expect(connected).toBe(1) })
  470. } finally {
  471. controller.stop()
  472. }
  473. })
  474. it('accepts only the first readiness report from one generation', async () => {
  475. const homes: string[] = []
  476. const source: ConnectionGenerationSource = (signal, ready) => {
  477. ready({ home: '/first' })
  478. ready({ home: '/duplicate' })
  479. return new Promise<void>((resolve) => {
  480. signal.addEventListener('abort', () => { resolve() }, { once: true })
  481. })
  482. }
  483. const controller = new ConnectionController(source, {
  484. onConnected: (host) => { homes.push(host.home) },
  485. }, FAST)
  486. controller.start()
  487. try {
  488. await vi.waitFor(() => { expect(homes).toEqual(['/first']) })
  489. } finally {
  490. controller.stop()
  491. }
  492. })
  493. it('does not announce readiness after a stop queued from the ready callback', async () => {
  494. const owner: { controller?: ConnectionController } = {}
  495. let sourceCalls = 0
  496. const connected = vi.fn()
  497. const source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
  498. sourceCalls++
  499. ready({ home: '/h' })
  500. queueMicrotask(() => { owner.controller?.stop() })
  501. signal.addEventListener('abort', () => { resolve() }, { once: true })
  502. })
  503. const controller = new ConnectionController(source, { onConnected: connected }, FAST)
  504. owner.controller = controller
  505. controller.start()
  506. await vi.waitFor(() => { expect(sourceCalls).toBe(1) })
  507. expect(connected).not.toHaveBeenCalled()
  508. })
  509. it('rejects a generation whose source ends during readiness and retries', async () => {
  510. const source = new FakeGenerationSource()
  511. source.holdReady = true
  512. const states: ConnectionState[] = []
  513. let connected = 0
  514. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  515. const controller = new ConnectionController(source.source, {
  516. onConnected: () => { connected++ },
  517. onStateChange: state => states.push(state),
  518. }, FAST)
  519. controller.start()
  520. try {
  521. await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
  522. source.holdReady = false
  523. source.end()
  524. await vi.waitFor(() => { expect(connected).toBe(1) })
  525. expect(states).toEqual(['connecting', 'connected'])
  526. } finally {
  527. controller.stop()
  528. warnSpy.mockRestore()
  529. }
  530. })
  531. it.each([
  532. { label: 'ends normally', fail: () => Promise.resolve() },
  533. {
  534. label: 'rejects with a non-Error reason',
  535. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test
  536. fail: () => Promise.reject('fixture offline'),
  537. },
  538. ])('retries when the generation source $label before reporting ready', async ({ fail }) => {
  539. let sourceCalls = 0
  540. let connected = 0
  541. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  542. const source: ConnectionGenerationSource = (signal, ready) => {
  543. sourceCalls++
  544. if (sourceCalls === 1) return fail()
  545. ready({ home: '/h' })
  546. return new Promise<void>((resolve) => {
  547. signal.addEventListener('abort', () => { resolve() }, { once: true })
  548. })
  549. }
  550. const controller = new ConnectionController(source, { onConnected: () => { connected++ } }, FAST)
  551. controller.start()
  552. try {
  553. await vi.waitFor(() => { expect(sourceCalls).toBe(2) })
  554. await vi.waitFor(() => { expect(connected).toBe(1) })
  555. } finally {
  556. controller.stop()
  557. warnSpy.mockRestore()
  558. }
  559. })
  560. it('accepts a slow Host after the warning and clears both readiness timers', async () => {
  561. vi.useFakeTimers()
  562. const source = new FakeGenerationSource()
  563. source.holdReady = true
  564. let connected = 0
  565. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  566. const controller = new ConnectionController(
  567. source.source,
  568. { onConnected: () => { connected++ } },
  569. { ...FAST, generationReadyWarnMs: 20, generationReadyTimeoutMs: 100 },
  570. )
  571. controller.start()
  572. try {
  573. await vi.advanceTimersByTimeAsync(0)
  574. expect(source.activeCount).toBe(1)
  575. await vi.advanceTimersByTimeAsync(20)
  576. expect(connected).toBe(0)
  577. expect(source.activeCount).toBe(1)
  578. expect(warnSpy).toHaveBeenCalledWith('[connection] generation is still not ready after 20ms')
  579. source.releaseReady()
  580. await vi.advanceTimersByTimeAsync(100)
  581. expect(connected).toBe(1)
  582. expect(source.activeCount).toBe(1)
  583. expect(vi.getTimerCount()).toBe(0)
  584. expect(warnSpy).toHaveBeenCalledTimes(1)
  585. } finally {
  586. controller.stop()
  587. warnSpy.mockRestore()
  588. vi.useRealTimers()
  589. }
  590. })
  591. it.each([20, 100, 200])('cancels an unready generation with warn=%i ms, waits for cleanup, and ignores late ready', async (generationReadyWarnMs) => {
  592. vi.useFakeTimers()
  593. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
  594. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  595. const cleanup = Promise.withResolvers<undefined>()
  596. const signals: AbortSignal[] = []
  597. const report: Array<(host: { home: string }) => void> = []
  598. const connected = vi.fn()
  599. const states: ConnectionState[] = []
  600. const source: ConnectionGenerationSource = async (signal, ready) => {
  601. signals.push(signal)
  602. report.push(ready)
  603. await new Promise<void>((resolve) => {
  604. signal.addEventListener('abort', () => { resolve() }, { once: true })
  605. })
  606. await cleanup.promise
  607. }
  608. const controller = new ConnectionController(source, {
  609. onConnected: connected,
  610. onStateChange: state => states.push(state),
  611. }, { ...FAST, generationReadyWarnMs, generationReadyTimeoutMs: 100 })
  612. controller.start()
  613. try {
  614. await vi.advanceTimersByTimeAsync(100)
  615. expect(signals[0]?.aborted).toBe(true)
  616. expect(signals[0]?.reason).toMatchObject({ message: 'connection generation was not ready within 100ms' })
  617. const warnings = generationReadyWarnMs <= 100
  618. ? [[`[connection] generation is still not ready after ${String(generationReadyWarnMs)}ms`]]
  619. : []
  620. warnings.push(['[connection] connection generation was not ready within 100ms; cancelling generation'])
  621. expect(warnSpy.mock.calls).toEqual(warnings)
  622. report[0]!({ home: '/stale' })
  623. await vi.advanceTimersByTimeAsync(1_000)
  624. expect(signals).toHaveLength(1)
  625. expect(connected).not.toHaveBeenCalled()
  626. expect(warnSpy.mock.calls).toEqual(warnings)
  627. cleanup.resolve(undefined)
  628. await vi.advanceTimersByTimeAsync(5)
  629. expect(signals).toHaveLength(2)
  630. report[1]!({ home: '/fresh' })
  631. await vi.advanceTimersByTimeAsync(0)
  632. expect(connected).toHaveBeenCalledExactlyOnceWith({ home: '/fresh' })
  633. expect(states).toEqual(['connecting', 'connected'])
  634. expect(vi.getTimerCount()).toBe(0)
  635. } finally {
  636. controller.stop()
  637. cleanup.resolve(undefined)
  638. await vi.advanceTimersByTimeAsync(0)
  639. randomSpy.mockRestore()
  640. warnSpy.mockRestore()
  641. vi.useRealTimers()
  642. }
  643. })
  644. it.each(['stop', 'reconnect', 'failure'] as const)('clears a pending handshake deadline on %s', async (action) => {
  645. vi.useFakeTimers()
  646. const source = new FakeGenerationSource()
  647. source.holdReady = true
  648. const connected = vi.fn()
  649. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  650. const controller = new ConnectionController(source.source, { onConnected: connected }, {
  651. ...FAST, generationReadyWarnMs: 20, generationReadyTimeoutMs: 100,
  652. })
  653. controller.start()
  654. try {
  655. await vi.advanceTimersByTimeAsync(10)
  656. source.holdReady = false
  657. if (action === 'failure') source.fail(new Error('carrier failed'))
  658. else controller[action]()
  659. await vi.advanceTimersByTimeAsync(0)
  660. source.releaseReady()
  661. await vi.advanceTimersByTimeAsync(200)
  662. expect(source.activeCount).toBe(action === 'stop' ? 0 : 1)
  663. expect(connected).toHaveBeenCalledTimes(action === 'stop' ? 0 : 1)
  664. expect(vi.getTimerCount()).toBe(0)
  665. expect(warnSpy.mock.calls.some(([message]) => String(message).includes('still not ready'))).toBe(false)
  666. expect(warnSpy.mock.calls.some(([message]) => String(message).includes('cancelling generation'))).toBe(false)
  667. } finally {
  668. controller.stop()
  669. await vi.advanceTimersByTimeAsync(0)
  670. warnSpy.mockRestore()
  671. vi.useRealTimers()
  672. }
  673. })
  674. it('emits the disconnected, retry-attempt, and connected transitions', async () => {
  675. const source = new FakeGenerationSource()
  676. const states: ConnectionState[] = []
  677. let connected = 0
  678. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  679. const controller = new ConnectionController(source.source, {
  680. onConnected: () => { connected++ },
  681. onStateChange: state => states.push(state),
  682. }, FAST)
  683. controller.start()
  684. try {
  685. await vi.waitFor(() => { expect(connected).toBe(1) })
  686. expect(states).toEqual(['connected'])
  687. source.fail(new Error('torn'))
  688. await vi.waitFor(() => { expect(connected).toBe(2) })
  689. expect(states).toEqual(['connected', 'connecting', 'connected'])
  690. } finally {
  691. controller.stop()
  692. warnSpy.mockRestore()
  693. }
  694. })
  695. it('does not announce a generation stopped synchronously by its connected state sink', async () => {
  696. const source = new FakeGenerationSource()
  697. const states: ConnectionState[] = []
  698. let connected = 0
  699. const controller = new ConnectionController(source.source, {
  700. onConnected: () => { connected++ },
  701. onStateChange: (state) => {
  702. states.push(state)
  703. if (state === 'connected') controller.stop()
  704. },
  705. }, FAST)
  706. controller.start()
  707. await vi.waitFor(() => { expect(states).toEqual(['connected']) })
  708. await vi.waitFor(() => { expect(source.activeCount).toBe(0) })
  709. expect(connected).toBe(0)
  710. })
  711. it('keeps one connecting state across consecutive retry attempts', async () => {
  712. let sourceCalls = 0
  713. const states: ConnectionState[] = []
  714. let connected = 0
  715. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  716. const source: ConnectionGenerationSource = (signal, ready) => {
  717. sourceCalls++
  718. if (sourceCalls <= 2) return Promise.reject(new Error('down'))
  719. ready({ home: '/h' })
  720. return new Promise<void>((resolve) => {
  721. signal.addEventListener('abort', () => { resolve() }, { once: true })
  722. })
  723. }
  724. const controller = new ConnectionController(source, {
  725. onConnected: () => { connected++ },
  726. onStateChange: state => states.push(state),
  727. }, FAST)
  728. controller.start()
  729. try {
  730. await vi.waitFor(() => { expect(sourceCalls).toBe(3) })
  731. await vi.waitFor(() => { expect(connected).toBe(1) })
  732. expect(states).toEqual(['connecting', 'connected'])
  733. } finally {
  734. controller.stop()
  735. warnSpy.mockRestore()
  736. }
  737. })
  738. it('runs with no sinks at all', async () => {
  739. const source = new FakeGenerationSource()
  740. const controller = new ConnectionController(source.source, {}, FAST)
  741. controller.start()
  742. try {
  743. await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
  744. } finally {
  745. controller.stop()
  746. }
  747. })
  748. it('start() is idempotent', async () => {
  749. const source = new FakeGenerationSource()
  750. let connected = 0
  751. const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
  752. controller.start()
  753. controller.start()
  754. try {
  755. await vi.waitFor(() => { expect(connected).toBe(1) })
  756. expect(source.activeCount).toBe(1)
  757. } finally {
  758. controller.stop()
  759. }
  760. })
  761. })