reconnect.mjs 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  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. if (options.fail) throw options.fail(attempts.length)
  35. throw new Error('暂时无法连接视频')
  36. },
  37. status: async () => ({ sessionId: 'session-A', status: 'FAILED' }),
  38. stop: async (session) => stopped(session),
  39. heartbeat: async () => {},
  40. }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
  41. return { live, attempts, updates, visible: () => visible }
  42. }
  43. test('live reconnects at most five times and never for the first normal play', async () => {
  44. const clock = new Clock(), h = failingLive(clock)
  45. try {
  46. const opening = h.live.start('A')
  47. await new Promise(setImmediate)
  48. await clock.advance(200000)
  49. await opening
  50. assert.equal(h.attempts.length, 6, 'one initial attempt plus five bounded retries')
  51. assert.equal(h.visible().phase, 'failed')
  52. } finally { await h.live.dispose() }
  53. })
  54. test('retries wait 2/4/8/16/30 seconds serially without stacking sessions', async () => {
  55. const clock = new Clock(), h = failingLive(clock)
  56. try {
  57. const opening = h.live.start('A')
  58. await new Promise(setImmediate)
  59. await clock.advance(200000)
  60. await opening
  61. const gaps = h.attempts.slice(1).map((attempt, index) => attempt.at - h.attempts[index].at)
  62. assert.deepEqual(gaps, [2000, 4000, 8000, 16000, 30000])
  63. const retrying = h.updates.filter((patch) => patch.phase === 'retrying').map((patch) => patch.retryAttempt)
  64. assert.deepEqual(retrying, [1, 2, 3, 4, 5], 'each waiting attempt is observable')
  65. } finally { await h.live.dispose() }
  66. })
  67. test('only thirty seconds of real playback restores the retry budget', async () => {
  68. // Control: playback shorter than the stable window keeps the already-spent budget,
  69. // so the following disconnect can only fan out from the remaining allowance.
  70. const short = await fixtureWithPlayback(29000)
  71. try {
  72. await short.drain()
  73. assert.equal(short.attempts.length, 6, 'a short window keeps the already-spent budget')
  74. } finally { await short.live.dispose() }
  75. // Full 30 seconds of playback replenishes the budget, so the next disconnect gets five fresh tries.
  76. const full = await fixtureWithPlayback(30000)
  77. try {
  78. await full.drain()
  79. assert.equal(full.attempts.length, 7, 'a full stable window restores the budget')
  80. } finally { await full.live.dispose() }
  81. })
  82. /** Plays successfully, stays up for `stableMs`, then drops and reports how many starts happened. */
  83. async function fixtureWithPlayback(stableMs) {
  84. const clock = new Clock(), attempts = []
  85. let healthy = false
  86. const live = createVideoLiveSession({
  87. prepare: async (code) => prepared(code),
  88. start: async () => { attempts.push(clock.now); if (healthy) return data('A'); throw new Error('暂时无法连接视频') },
  89. stop: async (session) => stopped(session), heartbeat: async () => {},
  90. }, () => {}, clock)
  91. const opening = live.start('A')
  92. await new Promise(setImmediate)
  93. healthy = true
  94. await clock.advance(2000)
  95. await opening
  96. live.ready()
  97. await clock.advance(stableMs)
  98. healthy = false
  99. live.fail('断流')
  100. await clock.advance(4000)
  101. return { live, attempts, drain: () => clock.advance(200000) }
  102. }
  103. test('unrecoverable outcomes never retry automatically', async () => {
  104. // VideoRequestError uses `category`; these are the real terminal categories from videoApi.
  105. for (const category of ['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'INVALID_RESPONSE']) {
  106. const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(category), { category }) } })
  107. try {
  108. const opening = h.live.start('A')
  109. await new Promise(setImmediate)
  110. await clock.advance(120000)
  111. await opening
  112. assert.equal(h.attempts.length, 1, `${category} must not be retried`)
  113. assert.equal(h.visible().phase, 'failed')
  114. } finally { await h.live.dispose() }
  115. }
  116. // The player reports permanent browser/media errors with `code`, not `category`.
  117. for (const code of ['VIDEO_URL_INVALID', 'VIDEO_UNSUPPORTED']) {
  118. const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(code), { code }) } })
  119. try {
  120. const opening = h.live.start('A')
  121. await new Promise(setImmediate)
  122. await clock.advance(120000)
  123. await opening
  124. assert.equal(h.attempts.length, 1, `${code} must not be retried`)
  125. assert.equal(h.visible().phase, 'failed')
  126. } finally { await h.live.dispose() }
  127. }
  128. // A temporary 503-style failure is retried.
  129. const clock = new Clock(), retryable = failingLive(clock, { fail: () => { throw Object.assign(new Error('暂时不可用'), { category: 'VIDEO_START_FAILED' }) } })
  130. try {
  131. const opening = retryable.live.start('A')
  132. await new Promise(setImmediate)
  133. await clock.advance(120000)
  134. await opening
  135. assert.equal(retryable.attempts.length, 6, 'a temporary failure spends the whole budget')
  136. } finally { await retryable.live.dispose() }
  137. })
  138. test('an exhausted budget names the reason instead of pretending it was the first failure', async () => {
  139. const clock = new Clock(), h = failingLive(clock)
  140. try {
  141. const opening = h.live.start('A')
  142. await new Promise(setImmediate)
  143. await clock.advance(200000)
  144. await opening
  145. assert.equal(h.visible().phase, 'failed')
  146. assert.ok(h.visible().error.includes('已自动重试 5 次仍失败'), h.visible().error)
  147. // Terminal state carries no stale retry indicators that the panel could still render.
  148. assert.equal(h.visible().retryAttempt, 0)
  149. assert.equal(h.visible().retriesLeft, 0)
  150. } finally { await h.live.dispose() }
  151. })
  152. test('closing during a retry wait both cancels and releases cleanly', async () => {
  153. const clock = new Clock(), h = failingLive(clock)
  154. try {
  155. const opening = h.live.start('A')
  156. await new Promise(setImmediate)
  157. // We are now inside the retry wait window: the failed attempt already released its identity.
  158. assert.equal(h.visible().phase, 'retrying')
  159. assert.equal(await h.live.stop(), true, 'closing in the wait window must report a clean release')
  160. await clock.advance(200000)
  161. await opening
  162. assert.equal(h.attempts.length, 1, 'no retry may run after close')
  163. assert.equal(h.visible().media, null)
  164. assert.equal(h.visible().cleanupError, '', 'a cleanly released viewer must not stay in cleanup pending')
  165. // The same viewer must be usable again; a stuck pending set would refuse this forever.
  166. await h.live.start('A')
  167. await new Promise(setImmediate)
  168. assert.equal(h.visible().cleanupError, '')
  169. assert.notEqual(h.visible().error, '上次清理结果尚未确认,暂不能重复建流')
  170. assert.equal(h.attempts.length, 2, 'reopening must actually attempt a new live start')
  171. } finally { await h.live.dispose() }
  172. })