reconnect.mjs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  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. // A temporary 503-style failure is retried.
  117. const clock = new Clock(), retryable = failingLive(clock, { fail: () => { throw Object.assign(new Error('暂时不可用'), { category: 'VIDEO_START_FAILED' }) } })
  118. try {
  119. const opening = retryable.live.start('A')
  120. await new Promise(setImmediate)
  121. await clock.advance(120000)
  122. await opening
  123. assert.equal(retryable.attempts.length, 6, 'a temporary failure spends the whole budget')
  124. } finally { await retryable.live.dispose() }
  125. })
  126. test('an exhausted budget names the reason instead of pretending it was the first failure', async () => {
  127. const clock = new Clock(), h = failingLive(clock)
  128. try {
  129. const opening = h.live.start('A')
  130. await new Promise(setImmediate)
  131. await clock.advance(200000)
  132. await opening
  133. assert.equal(h.visible().phase, 'failed')
  134. assert.ok(h.visible().error.includes('已自动重试 5 次仍失败'), h.visible().error)
  135. // Terminal state carries no stale retry indicators that the panel could still render.
  136. assert.equal(h.visible().retryAttempt, 0)
  137. assert.equal(h.visible().retriesLeft, 0)
  138. } finally { await h.live.dispose() }
  139. })
  140. test('closing cancels pending retries and releases the prepared identity', async () => {
  141. const clock = new Clock(), h = failingLive(clock)
  142. try {
  143. const opening = h.live.start('A')
  144. await new Promise(setImmediate)
  145. await h.live.stop()
  146. await clock.advance(200000)
  147. await opening
  148. assert.equal(h.attempts.length, 1, 'no retry may run after close')
  149. assert.equal(h.visible().media, null)
  150. } finally { await h.live.dispose() }
  151. })