浏览代码

fix(cockpit): 修复重连等待期关闭后清理卡死与播放器错误码丢失

ChenJZ 6 小时之前
父节点
当前提交
0283435e66

+ 1 - 1
src/modules/cockpit/components/VideoMonitorPanel.vue

@@ -13,7 +13,7 @@
                     <aside><h3>云台控制</h3><button type="button" disabled title="停止保障尚未验证,不提供物理控制">方向/变倍</button><p>能力尚未开放</p></aside>
                     <div class="video-monitor__screen">
                         <VideoPlayer v-if="state.media" :media="state.media" :options="playerOptions" @ready="video.ready" @error="video.fail" />
-                        <p v-else-if="state.phase === 'retrying'">连接中断,正在自动重连…</p>
+                        <p v-else-if="state.phase === 'retrying'">等待自动重连…</p>
                         <p v-else>{{ state.error || '等待视频连接' }}</p>
                     </div>
                 </div>

+ 9 - 5
src/modules/cockpit/services/videoLiveSession.js

@@ -26,7 +26,9 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
     // Retry policy: five automatic attempts after the first normal play, spaced 2/4/8/16/30s.
     const retryDelays = [2000, 4000, 8000, 16000, 30000]
     const stablePlaybackMs = 30000
-    const unrecoverable = new Set(['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'INVALID_RESPONSE', 'NOT_CONFIGURED', 'INVALID_REQUEST', 'UNSUPPORTED_MEDIA'])
+    // VideoRequestError carries `category`; the player and accepted errors use `code`.
+    const unrecoverable = new Set(['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'INVALID_RESPONSE',
+        'NOT_CONFIGURED', 'INVALID_REQUEST', 'UNSUPPORTED_MEDIA', 'VIDEO_URL_INVALID', 'VIDEO_UNSUPPORTED'])
     let retryBudget = retryDelays.length, retryTimer, retryAttempt = 0, stableTimer
     const publish = (patch) => { if (!disposed) update(patch) }
     const reportCleanup = () => publish({ cleanupError: releases.size ? cleanupMessage : '' })
@@ -75,7 +77,9 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
         cancelRetry()
         const attempt = current; current = null; clear(attempt)
         publish({ media: null, phase: 'idle' })
-        if (attempt?.identity) releases.add(attempt.identity)
+        // An already released identity must never re-enter the pending set: release() returns early
+        // for it, so the set would never shrink again and every later start would be refused.
+        if (attempt?.identity && !attempt.identity.released) releases.add(attempt.identity)
         cleanupChecks = 0
         for (const session of [...releases]) await release(session)
         reportCleanup()
@@ -120,9 +124,9 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
             publish({ phase: 'failed', error: reason, retryAttempt: 0, retryDelayMs: 0, retriesLeft: 0 })
             return
         }
-        generation = version
-        current = attempt
-        attempt.version = version
+        // `released` proved the old attempt is fully gone; only its cancellation token is restored so the
+        // scheduled retry still belongs to this viewing session. A fresh attempt is built by begin().
+        current = { version, deviceCode, identity: null, media: null }
         scheduleRetry(deviceCode, error.message)
     }
     function ready(media) {

+ 5 - 1
src/modules/cockpit/stores/videoStore.js

@@ -41,6 +41,10 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
     async function retry() { if (state.value.device) await session().start(state.value.device.deviceCode) }
     async function retryCleanup() { if (controller) await controller.stop() }
     const ready = () => controller?.ready()
-    const fail = (error) => controller?.fail(typeof error === 'string' ? error : error?.message || '视频播放失败')
+    // Keep the player's own failure code: without it a permanent browser/media error looks retryable.
+    const fail = (error, code) => {
+        if (code) controller?.fail(Object.assign(new Error(typeof error === 'string' ? error : error?.message || '视频播放失败'), { code }))
+        else controller?.fail(error instanceof Error ? error : error?.message || '视频播放失败')
+    }
     return { state, playerOptions, configure, loadDevice, open, close, retry, retryCleanup, ready, fail }
 })

+ 2 - 1
tests/cockpit-video/hosts.mjs

@@ -224,7 +224,8 @@ test('真实Root旅程中临时失败展示重试状态,关闭后不再自动
             assert.ok(predicate(), message)
         }
         await waitFor(() => liveCalls() >= 1, 'the first live attempt must run')
-        await waitFor(() => status().includes('自动重连') || status().includes('正在播放'), 'the retry state must be observable')
+        // The retry state itself must be visible, not merely skipped over on the way to recovery.
+        await waitFor(() => status().includes('自动重连'), 'the retry state must be observable')
         // Once the retried attempt produces media, the panel mounts a real video element again.
         await waitFor(() => Boolean(document.querySelector('[data-video-monitor] video')), 'the retried attempt must present media again')
         document.querySelector('[data-video-monitor] video').dispatchEvent(new Event('playing'))

+ 23 - 2
tests/cockpit-video/reconnect.mjs

@@ -121,6 +121,18 @@ test('unrecoverable outcomes never retry automatically', async () => {
             assert.equal(h.visible().phase, 'failed')
         } finally { await h.live.dispose() }
     }
+    // The player reports permanent browser/media errors with `code`, not `category`.
+    for (const code of ['VIDEO_URL_INVALID', 'VIDEO_UNSUPPORTED']) {
+        const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(code), { code }) } })
+        try {
+            const opening = h.live.start('A')
+            await new Promise(setImmediate)
+            await clock.advance(120000)
+            await opening
+            assert.equal(h.attempts.length, 1, `${code} must not be retried`)
+            assert.equal(h.visible().phase, 'failed')
+        } finally { await h.live.dispose() }
+    }
     // A temporary 503-style failure is retried.
     const clock = new Clock(), retryable = failingLive(clock, { fail: () => { throw Object.assign(new Error('暂时不可用'), { category: 'VIDEO_START_FAILED' }) } })
     try {
@@ -147,15 +159,24 @@ test('an exhausted budget names the reason instead of pretending it was the firs
     } finally { await h.live.dispose() }
 })
 
-test('closing cancels pending retries and releases the prepared identity', async () => {
+test('closing during a retry wait both cancels and releases cleanly', async () => {
     const clock = new Clock(), h = failingLive(clock)
     try {
         const opening = h.live.start('A')
         await new Promise(setImmediate)
-        await h.live.stop()
+        // We are now inside the retry wait window: the failed attempt already released its identity.
+        assert.equal(h.visible().phase, 'retrying')
+        assert.equal(await h.live.stop(), true, 'closing in the wait window must report a clean release')
         await clock.advance(200000)
         await opening
         assert.equal(h.attempts.length, 1, 'no retry may run after close')
         assert.equal(h.visible().media, null)
+        assert.equal(h.visible().cleanupError, '', 'a cleanly released viewer must not stay in cleanup pending')
+        // The same viewer must be usable again; a stuck pending set would refuse this forever.
+        await h.live.start('A')
+        await new Promise(setImmediate)
+        assert.equal(h.visible().cleanupError, '')
+        assert.notEqual(h.visible().error, '上次清理结果尚未确认,暂不能重复建流')
+        assert.equal(h.attempts.length, 2, 'reopening must actually attempt a new live start')
     } finally { await h.live.dispose() }
 })