reconnect.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import test from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { createVideoLiveSession } from '../../src/modules/cockpit/services/videoLiveSession.js'
  4. const media = (code) => ({ native: true, url: `https://media.invalid/${code}.mp4` })
  5. const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, mode: 'LIVE', status: 'ACTIVE', media: media(code) })
  6. const prepared = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', status: 'PREPARED' })
  7. const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' })
  8. class Clock {
  9. now = 0; next = 1; jobs = new Map()
  10. setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id }
  11. clearTimeout(id) { this.jobs.delete(id) }
  12. setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id }
  13. clearInterval(id) { this.clearTimeout(id) }
  14. async advance(duration) {
  15. const end = this.now + duration
  16. while (true) {
  17. const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0]
  18. if (!due) break
  19. const [id, job] = due; this.now = job.at
  20. if (job.interval) job.at += job.interval; else this.jobs.delete(id)
  21. job.fn(); await new Promise(setImmediate)
  22. }
  23. this.now = end; await new Promise(setImmediate)
  24. }
  25. }
  26. /** Builds a session whose live start always fails, and records every attempt. */
  27. function failingLive(clock, options = {}) {
  28. const attempts = [], updates = []
  29. let visible = {}
  30. const live = createVideoLiveSession({
  31. prepare: async (code) => prepared(code),
  32. start: async (session) => {
  33. attempts.push({ at: clock.now, code: session.deviceCode })
  34. options.onStart?.()
  35. if (options.fail) throw options.fail(attempts.length)
  36. throw new Error('暂时无法连接视频')
  37. },
  38. status: async () => ({ sessionId: 'session-A', status: 'FAILED' }),
  39. stop: async (session) => { options.onStop?.(); return stopped(session) },
  40. heartbeat: async () => {},
  41. }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
  42. return { live, attempts, updates, visible: () => visible }
  43. }
  44. test('live reconnects at most five times and never for the first normal play', async () => {
  45. const clock = new Clock(), h = failingLive(clock)
  46. try {
  47. const opening = h.live.start('A')
  48. await new Promise(setImmediate)
  49. await clock.advance(200000)
  50. await opening
  51. assert.equal(h.attempts.length, 6, 'one initial attempt plus five bounded retries')
  52. assert.equal(h.visible().phase, 'failed')
  53. } finally { await h.live.dispose() }
  54. })
  55. test('retries wait 2/4/8/16/30 seconds serially without stacking sessions', async () => {
  56. const clock = new Clock(), h = failingLive(clock)
  57. try {
  58. const opening = h.live.start('A')
  59. await new Promise(setImmediate)
  60. await clock.advance(200000)
  61. await opening
  62. const gaps = h.attempts.slice(1).map((attempt, index) => attempt.at - h.attempts[index].at)
  63. assert.deepEqual(gaps, [2000, 4000, 8000, 16000, 30000])
  64. const retrying = h.updates.filter((patch) => patch.phase === 'retrying').map((patch) => patch.retryAttempt)
  65. assert.deepEqual(retrying, [1, 2, 3, 4, 5], 'each waiting attempt is observable')
  66. } finally { await h.live.dispose() }
  67. })
  68. test('only thirty seconds of real playback restores the retry budget', async () => {
  69. // Control: playback shorter than the stable window keeps the already-spent budget,
  70. // so the following disconnect can only fan out from the remaining allowance.
  71. const short = await fixtureWithPlayback(29000)
  72. try {
  73. await short.drain()
  74. assert.equal(short.attempts.length, 6, 'a short window keeps the already-spent budget')
  75. } finally { await short.live.dispose() }
  76. // Full 30 seconds of playback replenishes the budget, so the next disconnect gets five fresh tries.
  77. const full = await fixtureWithPlayback(30000)
  78. try {
  79. await full.drain()
  80. assert.equal(full.attempts.length, 7, 'a full stable window restores the budget')
  81. } finally { await full.live.dispose() }
  82. })
  83. /** Plays successfully, stays up for `stableMs`, then drops and reports how many starts happened. */
  84. async function fixtureWithPlayback(stableMs) {
  85. const clock = new Clock(), attempts = []
  86. let healthy = false
  87. const live = createVideoLiveSession({
  88. prepare: async (code) => prepared(code),
  89. start: async () => { attempts.push(clock.now); if (healthy) return data('A'); throw new Error('暂时无法连接视频') },
  90. stop: async (session) => stopped(session), heartbeat: async () => {},
  91. }, () => {}, clock)
  92. const opening = live.start('A')
  93. await new Promise(setImmediate)
  94. healthy = true
  95. await clock.advance(2000)
  96. await opening
  97. live.ready()
  98. await clock.advance(stableMs)
  99. healthy = false
  100. live.fail('断流')
  101. await clock.advance(4000)
  102. return { live, attempts, drain: () => clock.advance(200000) }
  103. }
  104. test('unrecoverable outcomes never retry automatically', async () => {
  105. // VideoRequestError uses `category`; these are the real terminal categories from videoApi.
  106. for (const category of ['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'SESSION_EXPIRED', 'INVALID_RESPONSE']) {
  107. const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(category), { category }) } })
  108. try {
  109. const opening = h.live.start('A')
  110. await new Promise(setImmediate)
  111. await clock.advance(120000)
  112. await opening
  113. assert.equal(h.attempts.length, 1, `${category} must not be retried`)
  114. assert.equal(h.visible().phase, 'failed')
  115. } finally { await h.live.dispose() }
  116. }
  117. // The player reports permanent browser/media errors with `code`, not `category`.
  118. for (const code of ['VIDEO_URL_INVALID', 'VIDEO_UNSUPPORTED']) {
  119. const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(code), { code }) } })
  120. try {
  121. const opening = h.live.start('A')
  122. await new Promise(setImmediate)
  123. await clock.advance(120000)
  124. await opening
  125. assert.equal(h.attempts.length, 1, `${code} must not be retried`)
  126. assert.equal(h.visible().phase, 'failed')
  127. } finally { await h.live.dispose() }
  128. }
  129. // A temporary 503-style failure is retried.
  130. const clock = new Clock(), retryable = failingLive(clock, { fail: () => { throw Object.assign(new Error('暂时不可用'), { category: 'VIDEO_START_FAILED' }) } })
  131. try {
  132. const opening = retryable.live.start('A')
  133. await new Promise(setImmediate)
  134. await clock.advance(120000)
  135. await opening
  136. assert.equal(retryable.attempts.length, 6, 'a temporary failure spends the whole budget')
  137. } finally { await retryable.live.dispose() }
  138. })
  139. test('an exhausted budget names the reason instead of pretending it was the first failure', async () => {
  140. const clock = new Clock(), h = failingLive(clock)
  141. try {
  142. const opening = h.live.start('A')
  143. await new Promise(setImmediate)
  144. await clock.advance(200000)
  145. await opening
  146. assert.equal(h.visible().phase, 'failed')
  147. assert.ok(h.visible().error.includes('已自动重试 5 次仍失败'), h.visible().error)
  148. // Terminal state carries no stale retry indicators that the panel could still render.
  149. assert.equal(h.visible().retryAttempt, 0)
  150. assert.equal(h.visible().retriesLeft, 0)
  151. } finally { await h.live.dispose() }
  152. })
  153. test('closing during a retry wait both cancels and releases cleanly', async () => {
  154. const clock = new Clock(), h = failingLive(clock)
  155. try {
  156. const opening = h.live.start('A')
  157. await new Promise(setImmediate)
  158. // We are now inside the retry wait window: the failed attempt already released its identity.
  159. assert.equal(h.visible().phase, 'retrying')
  160. assert.equal(await h.live.stop(), true, 'closing in the wait window must report a clean release')
  161. await clock.advance(200000)
  162. await opening
  163. assert.equal(h.attempts.length, 1, 'no retry may run after close')
  164. assert.equal(h.visible().media, null)
  165. assert.equal(h.visible().cleanupError, '', 'a cleanly released viewer must not stay in cleanup pending')
  166. // The same viewer must be usable again; a stuck pending set would refuse this forever.
  167. await h.live.start('A')
  168. await new Promise(setImmediate)
  169. assert.equal(h.visible().cleanupError, '')
  170. assert.notEqual(h.visible().error, '上次清理结果尚未确认,暂不能重复建流')
  171. assert.equal(h.attempts.length, 2, 'reopening must actually attempt a new live start')
  172. } finally { await h.live.dispose() }
  173. })
  174. test('a manual replay after an exhausted budget starts a fresh budget', async () => {
  175. const clock = new Clock(), h = failingLive(clock)
  176. try {
  177. const opening = h.live.start('A')
  178. await new Promise(setImmediate)
  179. await clock.advance(200000)
  180. await opening
  181. assert.equal(h.visible().phase, 'failed')
  182. assert.ok(h.visible().error.includes('已自动重试 5 次仍失败'), h.visible().error)
  183. assert.equal(h.visible().retriesLeft, 0, 'an exhausted budget reports zero left')
  184. // A human clicking replay must get all five automatic attempts back.
  185. const replay = h.live.start('A')
  186. await new Promise(setImmediate)
  187. await clock.advance(200000)
  188. await replay
  189. assert.equal(h.attempts.length, 12, 'the manual replay spends a full second budget')
  190. } finally { await h.live.dispose() }
  191. })
  192. test('a late live response during the retry wait cannot present media for a stopped attempt', async () => {
  193. const clock = new Clock(), updates = []
  194. let visible = {}, respond
  195. const delayed = new Promise((resolve) => { respond = resolve })
  196. const live = createVideoLiveSession({
  197. prepare: async (code) => prepared(code),
  198. start: async () => delayed,
  199. stop: async (session) => stopped(session), heartbeat: async () => {},
  200. }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
  201. try {
  202. const opening = live.start('A')
  203. await new Promise(setImmediate)
  204. // The start deadline fires, so the attempt is cancelled and a retry is scheduled.
  205. await clock.advance(35000)
  206. assert.equal(visible.phase, 'retrying')
  207. // The cancelled attempt's ACTIVE response arrives late; it must never reach the panel.
  208. respond(data('A'))
  209. await new Promise(setImmediate); await new Promise(setImmediate)
  210. assert.equal(visible.media, null, 'a late response must not publish media')
  211. assert.equal(visible.phase, 'retrying')
  212. assert.equal(updates.some((patch) => patch.phase === 'connecting'), false)
  213. await clock.advance(200000)
  214. await opening
  215. } finally { await live.dispose() }
  216. })
  217. test('every attempt releases its own identity exactly once before the next begins', async () => {
  218. const clock = new Clock(), events = []
  219. const h = failingLive(clock, { onStop: () => events.push(['stop', clock.now]), onStart: () => events.push(['start', clock.now]) })
  220. try {
  221. const opening = h.live.start('A')
  222. await new Promise(setImmediate)
  223. await clock.advance(200000)
  224. await opening
  225. // Six attempts, six releases, alternating strictly so no two overlap.
  226. assert.deepEqual(events.map(([kind]) => kind), ['start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop'],
  227. 'every attempt releases exactly once before the next begins')
  228. } finally { await h.live.dispose() }
  229. })