Explorar o código

feat(cockpit): 实时视频五次有界重连与稳定播放复位预算

ChenJZ hai 6 horas
pai
achega
83626e54fe

+ 8 - 3
src/modules/cockpit/components/VideoMonitorPanel.vue

@@ -8,17 +8,19 @@
                 </header>
                 <div class="video-monitor__tabs"><strong>实时视频</strong><button type="button" disabled title="历史录像能力尚未接入">历史录像</button></div>
                 <p class="video-monitor__status" role="status">{{ labels[state.phase] || '未播放' }}</p>
+                <p v-if="retryNote" class="video-monitor__status" role="status">{{ retryNote }}</p>
                 <div class="video-monitor__body">
                     <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>{{ state.error || '等待视频连接' }}</p>
                     </div>
                 </div>
                 <p v-if="state.error && state.media" role="alert">{{ state.error }}</p>
                 <p v-if="state.cleanupError" class="video-monitor__warning" role="alert">{{ state.cleanupError }}</p>
                 <footer>
-                    <button v-if="!state.media && !['starting','connecting'].includes(state.phase)" type="button" :disabled="Boolean(state.cleanupError)" @click="video.retry">重新播放</button>
+                    <button v-if="!state.media && !['starting','connecting','retrying'].includes(state.phase)" type="button" :disabled="Boolean(state.cleanupError)" @click="video.retry">重新播放</button>
                     <button v-if="state.cleanupError" type="button" @click="video.retryCleanup">重试清理</button>
                     <span>默认静音 · 不提供截图、下载或录制</span>
                 </footer>
@@ -27,7 +29,7 @@
     </Teleport>
 </template>
 <script setup>
-import { watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
+import { watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useVideoStore } from '../stores/videoStore.js'
 import { useUiStore } from '../stores/uiStore.js'
@@ -36,7 +38,10 @@ import { useEnhancementDevicesStore } from '../stores/enhancementDevicesStore.js
 import VideoPlayer from './VideoPlayer.vue'
 const video = useVideoStore(), ui = useUiStore(), detail = useDetailStore(), enhancement = useEnhancementDevicesStore()
 const { state, playerOptions } = storeToRefs(video)
-const labels = { idle: '未播放', starting: '正在建立视频会话', connecting: '等待视频画面', playing: '正在播放', failed: '播放失败' }
+const labels = { idle: '未播放', starting: '正在建立视频会话', connecting: '等待视频画面', playing: '正在播放', retrying: '连接中断,正在自动重连', failed: '播放失败' }
+const retryNote = computed(() => state.value.phase !== 'retrying' || !state.value.retryAttempt
+    ? ''
+    : `第 ${state.value.retryAttempt} 次自动重试,等待 ${Math.round((state.value.retryDelayMs || 0) / 1000)} 秒(剩余 ${state.value.retriesLeft ?? 0} 次)`)
 watch(() => ui.mode, () => { void video.close() }, { flush: 'sync' })
 watch(() => [state.value.ownerSource, detail.selection.deviceCode, enhancement.selected?.code], ([ownerSource, detailCode, enhancementCode]) => {
     const currentCode = ownerSource === 'enhancement' ? enhancementCode : detailCode

+ 66 - 14
src/modules/cockpit/services/videoLiveSession.js

@@ -10,19 +10,24 @@
  * The above copyright notice and this permission notice shall be included in all
  * copies or substantial portions of the Software.
  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 import { selectVideoMedia, VideoRequestError } from './videoApi.js'
 
-/** One visible attempt, owned preparation before media, and a bounded cleanup observer. */
+/** One visible attempt, owned preparation before media, a bounded retry budget and cleanup observer. */
 export function createVideoLiveSession(api, update, timers = globalThis) {
     let generation = 0, current = null, cleanupTimer, cleanupChecks = 0, disposed = false
     const releases = new Set()
     const cleanupMessage = '会话释放未确认,等待服务端回收;可重试清理'
+    // 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'])
+    let retryBudget = retryDelays.length, retryTimer, retryAttempt = 0, stableTimer
     const publish = (patch) => { if (!disposed) update(patch) }
     const reportCleanup = () => publish({ cleanupError: releases.size ? cleanupMessage : '' })
     const active = (attempt) => !disposed && current === attempt && attempt.version === generation
@@ -30,6 +35,10 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
         if (!attempt) return
         timers.clearTimeout(attempt.startTimer); timers.clearTimeout(attempt.readyTimer); timers.clearInterval(attempt.heartbeat)
     }
+    const cancelRetry = () => {
+        timers.clearTimeout(retryTimer); retryTimer = undefined
+        timers.clearTimeout(stableTimer); stableTimer = undefined
+    }
     function scheduleCleanup() {
         if (disposed || cleanupTimer || !releases.size || cleanupChecks >= 40) return
         cleanupTimer = timers.setTimeout(async () => {
@@ -63,6 +72,7 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
     }
     async function stop() {
         ++generation
+        cancelRetry()
         const attempt = current; current = null; clear(attempt)
         publish({ media: null, phase: 'idle' })
         if (attempt?.identity) releases.add(attempt.identity)
@@ -71,25 +81,59 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
         reportCleanup()
         return releases.size === 0
     }
+    /** Only a temporary failure with budget left is retried; capability/ownership errors are final. */
+    function shouldRetry(error) {
+        if (disposed || !current || releases.size || retryBudget <= 0) return false
+        // VideoRequestError carries `category`; accepted errors use `code`.
+        const reason = error?.category || error?.code
+        return !unrecoverable.has(reason)
+    }    function scheduleRetry(deviceCode, message) {
+        const attempt = current
+        // Spend the budget here, before the timer exists, so the last retry cannot schedule another.
+        retryBudget -= 1
+        retryAttempt += 1
+        const delay = retryDelays[retryDelays.length - retryBudget - 1] ?? retryDelays[retryDelays.length - 1]
+        publish({ phase: 'retrying', error: message, retryAttempt, retryDelayMs: delay, retriesLeft: retryBudget })
+        retryTimer = timers.setTimeout(() => {
+            retryTimer = undefined
+            if (!active(attempt) || releases.size) return
+            void begin(deviceCode)
+        }, delay)
+    }
     async function fail(message, media) {
         if (!current || (media && current.media !== media)) return
-        publish({ error: message || '视频播放失败' })
+        const error = message instanceof Error ? message : Object.assign(new Error(message || '视频播放失败'), {})
+        const deviceCode = current.deviceCode
+        publish({ error: error.message || '视频播放失败' })
+        const attempt = current
+        const retryable = shouldRetry(error)
         const stopping = stop(), version = generation
-        await stopping
-        if (version === generation) publish({ phase: 'failed' })
+        const released = await stopping
+        if (disposed || version !== generation) return
+        if (!retryable || !released) { publish({ phase: 'failed' }); return }
+        generation = version
+        current = attempt
+        attempt.version = version
+        scheduleRetry(deviceCode, error.message)
     }
     function ready(media) {
         if (!current?.media || current.identity?.released || (media && current.media !== media)) return
         timers.clearTimeout(current.readyTimer)
         publish({ phase: 'playing' })
+        // A retry budget is earned only after real, sustained playback.
+        timers.clearTimeout(stableTimer)
+        const attempt = current
+        stableTimer = timers.setTimeout(() => {
+            if (!active(attempt)) return
+            retryBudget = retryDelays.length; retryAttempt = 0
+        }, stablePlaybackMs)
     }
-    async function start(deviceCode) {
-        const stopping = stop(), version = generation
-        await stopping
-        if (disposed || version !== generation) return
+    async function begin(deviceCode) {
+        if (disposed) return
         if (releases.size) {
             publish({ phase: 'failed', error: '上次清理结果尚未确认,暂不能重复建流' }); return
         }
+        const version = generation
         const attempt = { version, deviceCode, identity: null, media: null }
         current = attempt
         publish({ phase: 'starting', media: null, error: '', cleanupError: '' })
@@ -134,13 +178,21 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
                 finally { beating = false }
             }, 30000)
         } catch (error) {
-            if (active(attempt)) await fail(error.message || '视频连接失败')
+            if (active(attempt)) await fail(error)
             else if (attempt.identity) await release(attempt.identity)
         } finally {
             timers.clearTimeout(attempt.startTimer)
             reportCleanup()
         }
     }
+    async function start(deviceCode) {
+        const stopping = stop(), version = generation
+        await stopping
+        if (disposed || version !== generation) return
+        // A manual replay starts a fresh budget; automatic retries never call this.
+        retryBudget = retryDelays.length; retryAttempt = 0
+        await begin(deviceCode)
+    }
     async function dispose() { await stop(); disposed = true; timers.clearTimeout(cleanupTimer) }
     return { start, stop, dispose, ready, fail }
 }

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

@@ -93,7 +93,7 @@ async function mountRoot({ positioned = false, underground = false } = {}) {
         longitude: positioned ? 110.4 : null, latitude: positioned ? 28.4 : null, coordinateValidation: location }
     const selectedCamera = { ...camera, longitude: selectedPoint.longitude, latitude: selectedPoint.latitude, coordinateValidation: location }
     const requests = [], unexpected = [], errors = []
-    const server = { snapshotGate: null, pointGate: null, pointFails: false, cameraPresent: true }
+    const server = { snapshotGate: null, pointGate: null, pointFails: false, cameraPresent: true, liveFailures: 0 }
     const fetchImpl = async (address, options = {}) => {
         const url = new URL(address), path = url.pathname.replace('/api/cockpit/v1', '')
         const body = options.body ? JSON.parse(options.body) : null
@@ -134,6 +134,11 @@ async function mountRoot({ positioned = false, underground = false } = {}) {
         }
         if (path === '/video/sessions/session-host/live') {
             assert.deepEqual(body, { sessionToken: 'owner-host' })
+            // A controllable temporary failure lets the real journey exercise bounded reconnection.
+            if (server.liveFailures > 0) {
+                server.liveFailures -= 1
+                return { ok: false, status: 503, json: async () => ({ code: 503, message: '视频暂时不可用' }) }
+            }
             return envelope({ sessionId: 'session-host', mode: 'LIVE', status: 'ACTIVE',
                 media: { native: true, url: 'https://media.invalid/live.mp4' } })
         }
@@ -205,6 +210,46 @@ for (const positioned of [false, true]) test(`${positioned ? '浮动' : '无地
     } finally { await page.dispose() }
 })
 
+test('真实Root旅程中临时失败展示重试状态,关闭后不再自动重连', async () => {
+    const page = await mountRoot()
+    try {
+        // Two temporary live failures: the journey must show a bounded retry and then recover by itself.
+        page.server.liveFailures = 2
+        await openMainMonitor(page)
+        const status = () => document.querySelector('[data-video-monitor]')?.textContent || ''
+        const liveCalls = () => page.requests.filter((request) => request.path.endsWith('/live')).length
+        const waitFor = async (predicate, message, timeout = 15000) => {
+            const deadline = Date.now() + timeout
+            while (Date.now() < deadline) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 100)) }
+            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')
+        // 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'))
+        await settle()
+        await waitFor(() => status().includes('正在播放'), 'the journey must recover without a manual click')
+        assert.equal(liveCalls(), 3, 'exactly the two failed attempts plus the recovered one')
+        // Each attempt owns its own prepared identity, and no two are ever in flight at once.
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 3,
+            'every attempt prepares exactly one identity')
+        assert.ok(page.requests.filter((request) => request.path.endsWith('/stop')).length >= 1,
+            'a failed attempt releases its identity before the next one starts')
+        // Closing during a later outage must cancel the pending attempt.
+        page.server.liveFailures = 4
+        document.querySelector('[data-video-monitor] video')?.dispatchEvent(new Event('error'))
+        await new Promise((resolve) => setTimeout(resolve, 300))
+        document.querySelector('[aria-label="关闭监控"]').click(); await settle()
+        const settled = liveCalls()
+        await new Promise((resolve) => setTimeout(resolve, 4000))
+        assert.equal(liveCalls(), settled, 'no retry may fire after the monitor closed')
+        assert.equal(document.querySelector('[data-video-monitor]'), null)
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
 test('已关闭的设备详情入口同轮迟到点击不得创建观看', async () => {
     const page = await mountRoot()
     try {

+ 23 - 9
tests/cockpit-video/journey.mjs

@@ -153,9 +153,13 @@ test('watching heartbeat preserves its session ownership and failure releases th
         assert.deepEqual(beat.body, { sessionToken: 'owner-a' })
         wrong = true
         await clock.advance(30000)
+        // Ticket 04: a dropped session is temporary, so the bounded retry runs before the terminal reason.
+        assert.ok(dialog.textContent.includes('自动重连'), dialog.textContent)
+        await clock.advance(400000)
+        // After the budget is spent the panel must show a terminal failure and drop the media.
+        assert.ok(dialog.textContent.includes('播放失败') || dialog.textContent.includes('播放会话已断开'), dialog.textContent)
         assert.equal(dialog.querySelector('video'), null)
-        assert.ok(dialog.textContent.includes('播放会话已断开'))
-        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+        assert.ok(page.requests.filter((request) => request.path.endsWith('/stop')).length >= 1)
     } finally { await page.dispose() }
 })
 
@@ -197,12 +201,15 @@ test('non-camera has no monitor button; unbound and unknown live capability stay
 })
 
 test('unsupported returned media releases its owned session and never presents playing', async () => {
-    const page = await mountJourney({ live: () => envelope(livePayload({ native: false, flv: 'https://media.invalid/live.flv' })) })
+    const clock = new TestClock()
+    const page = await mountJourney({ sessionOptions: { timers: clock }, live: () => envelope(livePayload({ native: false, flv: 'https://media.invalid/live.flv' })) })
     try {
         const dialog = await openMonitor(page)
         assert.equal(dialog.querySelector('video'), null)
-        assert.ok(dialog.textContent.includes('不支持'))
+        // Unsupported media is a capability outcome, never a temporary outage: no retry is spent.
+        assert.ok(dialog.textContent.includes('不支持'), dialog.textContent)
         assert.equal(dialog.textContent.includes('正在播放'), false)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/live')).length, 1, 'an unsupported payload must not be retried')
         assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
     } finally { await page.dispose() }
 })
@@ -225,13 +232,15 @@ test('a live start exceeding 35 seconds exits visibly and releases a later owned
     try {
         const dialog = await openMonitor(page)
         await clock.advance(35000)
-        assert.ok(dialog.textContent.includes('视频建立超时'))
+        assert.ok(dialog.textContent.includes('自动重连'), dialog.textContent)
+        await clock.advance(400000)
+        assert.ok(dialog.textContent.includes('视频建立超时'), dialog.textContent)
         assert.equal(dialog.querySelector('video'), null)
         respond(envelope(livePayload({ native: true, url: 'https://media.invalid/live.mp4' })))
         await settle()
         assert.equal(dialog.querySelector('video'), null)
-        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
-        assert.equal(page.requests.filter((request) => request.path.endsWith('/live')).length, 1)
+        assert.ok(page.requests.filter((request) => request.path.endsWith('/stop')).length >= 1)
+        assert.ok(page.requests.filter((request) => request.path.endsWith('/live')).length >= 1)
     } finally { await page.dispose() }
 })
 
@@ -242,9 +251,14 @@ test('player readiness has a bound independent of HTTP success and cleans its ow
         const dialog = await openMonitor(page)
         assert.ok(dialog.textContent.includes('等待视频画面'))
         await clock.advance(20000)
-        assert.ok(dialog.textContent.includes('播放器就绪超时'))
+        // Ticket 04: an unreachable player is a temporary failure, so the retry state shows first.
+        assert.ok(dialog.textContent.includes('自动重连'), dialog.textContent)
         assert.equal(dialog.querySelector('video'), null)
-        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+        // Exhausting the bounded budget is what finally surfaces the terminal reason.
+        await clock.advance(400000)
+        assert.ok(dialog.textContent.includes('播放器就绪超时'), dialog.textContent)
+        assert.equal(dialog.querySelector('video'), null)
+        assert.ok(page.requests.filter((request) => request.path.endsWith('/stop')).length >= 1)
     } finally { await page.dispose() }
 })
 

+ 146 - 0
tests/cockpit-video/reconnect.mjs

@@ -0,0 +1,146 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { createVideoLiveSession } from '../../src/modules/cockpit/services/videoLiveSession.js'
+
+const media = (code) => ({ native: true, url: `https://media.invalid/${code}.mp4` })
+const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, mode: 'LIVE', status: 'ACTIVE', media: media(code) })
+const prepared = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', status: 'PREPARED' })
+const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' })
+
+class Clock {
+    now = 0; next = 1; jobs = new Map()
+    setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id }
+    clearTimeout(id) { this.jobs.delete(id) }
+    setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id }
+    clearInterval(id) { this.clearTimeout(id) }
+    async advance(duration) {
+        const end = this.now + duration
+        while (true) {
+            const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0]
+            if (!due) break
+            const [id, job] = due; this.now = job.at
+            if (job.interval) job.at += job.interval; else this.jobs.delete(id)
+            job.fn(); await new Promise(setImmediate)
+        }
+        this.now = end; await new Promise(setImmediate)
+    }
+}
+
+/** Builds a session whose live start always fails, and records every attempt. */
+function failingLive(clock, options = {}) {
+    const attempts = [], updates = []
+    let visible = {}
+    const live = createVideoLiveSession({
+        prepare: async (code) => prepared(code),
+        start: async (session) => {
+            attempts.push({ at: clock.now, code: session.deviceCode })
+            if (options.fail) throw options.fail(attempts.length)
+            throw new Error('暂时无法连接视频')
+        },
+        status: async () => ({ sessionId: 'session-A', status: 'FAILED' }),
+        stop: async (session) => stopped(session),
+        heartbeat: async () => {},
+    }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
+    return { live, attempts, updates, visible: () => visible }
+}
+
+test('live reconnects at most five times and never for the first normal play', async () => {
+    const clock = new Clock(), h = failingLive(clock)
+    try {
+        const opening = h.live.start('A')
+        await new Promise(setImmediate)
+        await clock.advance(200000)
+        await opening
+        assert.equal(h.attempts.length, 6, 'one initial attempt plus five bounded retries')
+        assert.equal(h.visible().phase, 'failed')
+    } finally { await h.live.dispose() }
+})
+
+test('retries wait 2/4/8/16/30 seconds serially without stacking sessions', async () => {
+    const clock = new Clock(), h = failingLive(clock)
+    try {
+        const opening = h.live.start('A')
+        await new Promise(setImmediate)
+        await clock.advance(200000)
+        await opening
+        const gaps = h.attempts.slice(1).map((attempt, index) => attempt.at - h.attempts[index].at)
+        assert.deepEqual(gaps, [2000, 4000, 8000, 16000, 30000])
+        const retrying = h.updates.filter((patch) => patch.phase === 'retrying').map((patch) => patch.retryAttempt)
+        assert.deepEqual(retrying, [1, 2, 3, 4, 5], 'each waiting attempt is observable')
+    } finally { await h.live.dispose() }
+})
+
+test('only thirty seconds of real playback restores the retry budget', async () => {
+    // Control: playback shorter than the stable window keeps the already-spent budget,
+    // so the following disconnect can only fan out from the remaining allowance.
+    const short = await fixtureWithPlayback(29000)
+    try {
+        await short.drain()
+        assert.equal(short.attempts.length, 6, 'a short window keeps the already-spent budget')
+    } finally { await short.live.dispose() }
+    // Full 30 seconds of playback replenishes the budget, so the next disconnect gets five fresh tries.
+    const full = await fixtureWithPlayback(30000)
+    try {
+        await full.drain()
+        assert.equal(full.attempts.length, 7, 'a full stable window restores the budget')
+    } finally { await full.live.dispose() }
+})
+
+/** Plays successfully, stays up for `stableMs`, then drops and reports how many starts happened. */
+async function fixtureWithPlayback(stableMs) {
+    const clock = new Clock(), attempts = []
+    let healthy = false
+    const live = createVideoLiveSession({
+        prepare: async (code) => prepared(code),
+        start: async () => { attempts.push(clock.now); if (healthy) return data('A'); throw new Error('暂时无法连接视频') },
+        stop: async (session) => stopped(session), heartbeat: async () => {},
+    }, () => {}, clock)
+    const opening = live.start('A')
+    await new Promise(setImmediate)
+    healthy = true
+    await clock.advance(2000)
+    await opening
+    live.ready()
+    await clock.advance(stableMs)
+    healthy = false
+    live.fail('断流')
+    await clock.advance(4000)
+    return { live, attempts, drain: () => clock.advance(200000) }
+}
+
+test('unrecoverable outcomes never retry automatically', async () => {
+    // VideoRequestError uses `category`; these are the real terminal categories from videoApi.
+    for (const category of ['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'INVALID_RESPONSE']) {
+        const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(category), { category }) } })
+        try {
+            const opening = h.live.start('A')
+            await new Promise(setImmediate)
+            await clock.advance(120000)
+            await opening
+            assert.equal(h.attempts.length, 1, `${category} 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 {
+        const opening = retryable.live.start('A')
+        await new Promise(setImmediate)
+        await clock.advance(120000)
+        await opening
+        assert.equal(retryable.attempts.length, 6, 'a temporary failure spends the whole budget')
+    } finally { await retryable.live.dispose() }
+})
+
+test('closing cancels pending retries and releases the prepared identity', async () => {
+    const clock = new Clock(), h = failingLive(clock)
+    try {
+        const opening = h.live.start('A')
+        await new Promise(setImmediate)
+        await h.live.stop()
+        await clock.advance(200000)
+        await opening
+        assert.equal(h.attempts.length, 1, 'no retry may run after close')
+        assert.equal(h.visible().media, null)
+    } finally { await h.live.dispose() }
+})