Browse Source

fix: drop orphan live leases after late join

Invalidate in-flight join with generation/disposed, leave returned sessions
immediately when the page already left, release via keepalive on pagehide,
and rejoin on heartbeat/play-url 57006 instead of refreshing stale URLs.
main
xiaosi 2 weeks ago
parent
commit
822fb09f5a
  1. 16
      src/api/live.js
  2. 145
      src/composables/useMonitorLive.js
  3. 6
      src/utils/http.js
  4. 89
      src/views/MediaView/MediaView.vue
  5. 9
      src/views/MonitorView/MonitorView.vue

16
src/api/live.js

@ -22,6 +22,22 @@ export function leaveLive(dockId, sessionId) {
return request.delete(`/v1/live/${dockId}/sessions/${sessionId}/viewers/me`)
}
/** pagehide/unload: best-effort leave that outlives the page */
export function leaveLiveKeepalive(dockId, sessionId) {
if (!dockId || !sessionId || typeof fetch !== 'function') return
const token = localStorage.getItem('token') || ''
const headers = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
try {
fetch(`/api/v1/live/${encodeURIComponent(dockId)}/sessions/${encodeURIComponent(sessionId)}/viewers/me`, {
method: 'DELETE',
headers,
keepalive: true,
credentials: 'same-origin',
}).catch(() => {})
} catch (_) {}
}
export function stopLive(dockId) {
return request.post(`/v1/live/${dockId}/stop`)
}

145
src/composables/useMonitorLive.js

@ -5,6 +5,7 @@ import {
heartbeatLive,
joinLive,
leaveLive,
leaveLiveKeepalive,
stopLive,
} from '@/api/live'
@ -13,6 +14,7 @@ const PHASE_TIMEOUT_MS = 60_000
const PLAY_RETRY_MAX = 3
const PLAY_EXPIRE_FALLBACK_SEC = 270
const PLAY_REFRESH_LEAD_MS = 30_000
const LEASE_INVALID_CODE = 57006
function isFatalPlayMediaError(err) {
const msg = String(err?.message || err || '').toLowerCase()
@ -29,6 +31,15 @@ function isFatalPlayMediaError(err) {
)
}
function isLeaseInvalidError(err) {
return Number(err?.code) === LEASE_INVALID_CODE
}
function safeLeave(dockId, sessionId) {
if (!dockId || !sessionId) return Promise.resolve()
return leaveLive(dockId, sessionId).catch(() => {})
}
export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
const live = reactive({
session: null,
@ -44,6 +55,9 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
})
let playURLRequest = null
let disposed = false
let openGen = 0
let rejoinBusy = false
const livePhaseText = computed(() => {
if (live.opening && !live.session) return '打开中'
@ -80,6 +94,10 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
live.phasePollStartedAt = 0
}
function isCurrentOpen(gen) {
return !disposed && gen === openGen
}
function markPlayFatal(message) {
window.clearTimeout(live.playRefreshTimer)
live.playRefreshTimer = null
@ -90,7 +108,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}
function toggleLive() {
if (live.opening) return
if (disposed || live.opening) return
if (live.session) closeLive()
else openLive()
}
@ -105,53 +123,72 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}
async function openLive() {
if (live.opening || live.session) return
if (disposed || live.opening || live.session) return
const dockId = getDockId?.()
if (!dockId) return
const gen = ++openGen
live.opening = true
try {
const result = await joinLive(dockId)
live.session = result.session
const session = result?.session
const sessionId = session?.id
// 页面已切走/关闭:不要写入状态,立刻释放刚拿到的 viewer lease
if (!isCurrentOpen(gen)) {
if (sessionId) await safeLeave(dockId, sessionId)
return
}
if (!sessionId) {
ui.toast('直播会话无效')
return
}
live.session = session
live.dockId = dockId
live.playUrl = ''
live.playUrlExpiresAt = 0
live.playRetryCount = 0
scheduleLiveHeartbeat(result.leaseExpiresAt)
if (result.session.phase === 'streaming') {
if (session.phase === 'streaming') {
try {
await refreshPlayURL()
} catch (e) {
if (!isCurrentOpen(gen)) return
await onPlayError(e)
}
} else {
startPhasePolling()
}
} catch (e) {
if (!isCurrentOpen(gen)) return
ui.toast(e.message || '打开直播失败')
} finally {
live.opening = false
if (gen === openGen) live.opening = false
}
}
function startPhasePolling() {
if (disposed) return
window.clearTimeout(live.phasePollTimer)
live.phasePollStartedAt = Date.now()
live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS)
}
async function pollPhaseOnce() {
if (!live.dockId || !live.session?.id) return
if (disposed || !live.dockId || !live.session?.id) return
if (Date.now() - live.phasePollStartedAt > PHASE_TIMEOUT_MS) {
ui.toast('直播启动超时')
await closeLive(false)
return
}
const dockId = live.dockId
const sessionId = live.session.id
try {
// http 解包后是 LiveSession 本体,不是 { session }
const session = await getLiveSession(live.dockId, sessionId)
if (!live.session || live.session.id !== sessionId) return
const session = await getLiveSession(dockId, sessionId)
if (disposed || !live.session || live.session.id !== sessionId) return
live.session = session
if (session.phase === 'streaming') {
window.clearTimeout(live.phasePollTimer)
@ -159,6 +196,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try {
await refreshPlayURL()
} catch (e) {
if (disposed || !live.session || live.session.id !== sessionId) return
await onPlayError(e)
}
return
@ -169,20 +207,27 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
return
}
} catch (e) {
if (disposed || !live.session || live.session.id !== sessionId) return
if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid()
return
}
ui.toast(e.message || '查询直播状态失败')
await closeLive(false)
return
}
if (disposed || !live.session || live.session.id !== sessionId) return
live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS)
}
// 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归)
// fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建
async function refreshPlayURLOnce({ fromRetry = false } = {}) {
if (!live.dockId || !live.session?.id) return false
if (disposed || !live.dockId || !live.session?.id) return false
const dockId = live.dockId
const sessionId = live.session.id
const play = await getLivePlayURL(live.dockId, sessionId)
if (!live.session || live.session.id !== sessionId) return false
const play = await getLivePlayURL(dockId, sessionId)
if (disposed || !live.session || live.session.id !== sessionId) return false
live.playUrl = play.playUrl || ''
const nowSec = Math.floor(Date.now() / 1000)
live.playUrlExpiresAt = Number(play.expiresAt) || nowSec + PLAY_EXPIRE_FALLBACK_SEC
@ -200,6 +245,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}
function schedulePlayRefresh() {
if (disposed) return
window.clearTimeout(live.playRefreshTimer)
if (!live.playUrl || live.playUrl.startsWith('fake://') || !live.playUrlExpiresAt) return
const delay = Math.max(5000, live.playUrlExpiresAt * 1000 - Date.now() - PLAY_REFRESH_LEAD_MS)
@ -207,44 +253,78 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try {
await refreshPlayURL()
} catch (e) {
if (disposed) return
if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid()
return
}
await onPlayError(e)
}
}, delay)
}
function scheduleLiveHeartbeat(expiresAt) {
if (disposed) return
window.clearTimeout(live.heartbeatTimer)
const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2)
live.heartbeatTimer = window.setTimeout(async () => {
if (!live.dockId || !live.session?.id) return
if (disposed || !live.dockId || !live.session?.id) return
const dockId = live.dockId
const sessionId = live.session.id
try {
const result = await heartbeatLive(dockId, sessionId)
if (!live.session || live.session.id !== sessionId || live.dockId !== dockId) return
if (disposed || !live.session || live.session.id !== sessionId || live.dockId !== dockId) return
live.session = result.session
scheduleLiveHeartbeat(result.leaseExpiresAt)
if (result.session.phase === 'streaming' && !live.playUrl) {
try {
await refreshPlayURL()
} catch (e) {
if (disposed || !live.session || live.session.id !== sessionId) return
if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid()
return
}
await onPlayError(e)
}
}
} catch (e) {
if (!live.session || live.session.id !== sessionId || live.dockId !== dockId) return
if (disposed || !live.session || live.session.id !== sessionId || live.dockId !== dockId) return
if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid()
return
}
ui.toast(e.message || '直播观看已结束')
await closeLive(false)
}
}, delay)
}
async function rejoinAfterLeaseInvalid() {
if (disposed || rejoinBusy) return
rejoinBusy = true
try {
// 租约失效:停掉旧 session 的 play-url/heartbeat,再重新 join
await closeLive(false)
if (disposed) return
ui.toast('观看租约已失效,正在重新加入直播')
await openLive()
} finally {
rejoinBusy = false
}
}
async function onPlayError(err) {
if (!live.session) return
if (disposed || !live.session) return
// AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl.
if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return
if (isLeaseInvalidError(err)) {
await rejoinAfterLeaseInvalid()
return
}
// 播放器媒体面失败(TLS/CORS/HLS network):换 auth_key 没用,禁止刷 play-url
if (isFatalPlayMediaError(err) || err?.name === 'HlsError') {
markPlayFatal(
@ -264,6 +344,11 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try {
await refreshPlayURL({ fromRetry: true })
} catch (e) {
if (disposed) return
if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid()
return
}
if (isFatalPlayMediaError(e) || e?.name === 'HlsError' || live.playRetryCount >= PLAY_RETRY_MAX) {
markPlayFatal(
isFatalPlayMediaError(e)
@ -277,18 +362,32 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}
async function closeLive(sendRequest = true) {
// 使 in-flight openLive/join 失效;迟到的 join 结果会走 orphan leave
openGen += 1
const session = live.session
const dockId = live.dockId
resetLocal()
if (sendRequest && session && dockId) {
try {
await leaveLive(dockId, session.id)
} catch (_) {}
await safeLeave(dockId, session.id)
}
}
/** 页面卸载/刷新:立即失效 generation,并用 keepalive 释放租约 */
function dispose({ keepalive = false } = {}) {
if (disposed) return
disposed = true
openGen += 1
const session = live.session
const dockId = live.dockId
resetLocal()
if (session && dockId) {
if (keepalive) leaveLiveKeepalive(dockId, session.id)
else safeLeave(dockId, session.id)
}
}
async function stopLiveStream() {
if (!live.session || !live.dockId || live.opening) return
if (disposed || !live.session || !live.dockId || live.opening) return
const dockId = live.dockId
const session = live.session
const ok = await ui.confirm(
@ -298,13 +397,12 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
)
if (!ok) return
// confirm 期间可能已关闭/切换会话,避免 stop 错 dock 或 wipe 新会话
if (!live.session || live.dockId !== dockId || live.session.id !== session.id) return
if (disposed || !live.session || live.dockId !== dockId || live.session.id !== session.id) return
try {
await stopLive(dockId)
openGen += 1
resetLocal()
try {
await leaveLive(dockId, session.id)
} catch (_) {}
await safeLeave(dockId, session.id)
ui.toast('直播已停止')
} catch (e) {
ui.toast(e.message || '停止直播失败')
@ -319,6 +417,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
fullscreenLive,
openLive,
closeLive,
dispose,
stopLiveStream,
onPlayError,
}

6
src/utils/http.js

@ -28,7 +28,10 @@ request.interceptors.response.use(
if (!response.config.skipErrorToast) {
useUiStore().toast(res?.msg || '请求失败')
}
return Promise.reject(new Error(res?.msg || '请求失败'))
const err = new Error(res?.msg || '请求失败')
err.code = res?.code
err.response = response
return Promise.reject(err)
},
(error) => {
const status = error.response?.status
@ -47,6 +50,7 @@ request.interceptors.response.use(
}
const err = new Error(message)
err.status = status
err.code = data?.code
err.response = error.response
return Promise.reject(err)
}

89
src/views/MediaView/MediaView.vue

@ -76,7 +76,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http'
import * as urls from '@/config/urls'
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live'
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive, leaveLiveKeepalive } from '@/api/live'
import MediaCard from '@/components/MediaCard.vue'
const route = useRoute()
@ -107,7 +107,19 @@ const activeLive = reactive({
})
let heartbeatTimer = null
let openSeq = 0
let disposed = false
let rejoinBusy = false
const LEASE_INVALID_CODE = 57006
function isLeaseInvalidError(err) {
return Number(err?.code) === LEASE_INVALID_CODE
}
function safeLeave(dockId, sessionId) {
if (!dockId || !sessionId) return Promise.resolve()
return leaveLive(dockId, sessionId).catch(() => {})
}
const liveTabLabel = computed(() => `直播中心 ${liveStreams.value.length}`)
const videosTabLabel = computed(() => `原始视频 ${videos.value.length}`)
@ -142,19 +154,24 @@ function resetActiveLive() {
function scheduleHeartbeat(expiresAt) {
clearHeartbeat()
if (!activeLive.dockId || !activeLive.sessionId) return
if (disposed || !activeLive.dockId || !activeLive.sessionId) return
const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2)
heartbeatTimer = window.setTimeout(async () => {
if (!activeLive.dockId || !activeLive.sessionId) return
if (disposed || !activeLive.dockId || !activeLive.sessionId) return
const dockId = activeLive.dockId
const sessionId = activeLive.sessionId
const streamId = activeLive.id
try {
const result = await heartbeatLive(dockId, sessionId)
if (activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return
if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return
if (result?.session?.phase) activeLive.phase = result.session.phase
scheduleHeartbeat(result.leaseExpiresAt)
} catch (e) {
if (activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return
if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return
if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid(streamId, dockId)
return
}
ui.toast(e.message || '直播观看已结束')
resetActiveLive()
}
@ -162,13 +179,26 @@ function scheduleHeartbeat(expiresAt) {
}
async function closeActiveLive({ sendLeave = true } = {}) {
openSeq += 1
const dockId = activeLive.dockId
const sessionId = activeLive.sessionId
resetActiveLive()
if (sendLeave && dockId && sessionId) {
await safeLeave(dockId, sessionId)
}
}
async function rejoinAfterLeaseInvalid(streamId, dockId) {
if (disposed || rejoinBusy) return
rejoinBusy = true
try {
await leaveLive(dockId, sessionId)
} catch (_) {}
await closeActiveLive({ sendLeave: false })
if (disposed) return
ui.toast('观看租约已失效,正在重新加入直播')
const stream = liveStreams.value.find((s) => s.id === streamId) || { id: streamId, dockId }
await openLive(stream)
} finally {
rejoinBusy = false
}
}
@ -206,27 +236,35 @@ async function load() {
}
async function openLive(stream) {
if (!stream?.dockId) return
if (disposed || !stream?.dockId) return
if (activeLive.id === stream.id && activeLive.playUrl) {
await closeActiveLive()
return
}
const seq = ++openSeq
// closeActiveLive bump openSeq使 in-flight join seq
await closeActiveLive()
const seq = ++openSeq
try {
const result = await joinLive(stream.dockId)
if (seq !== openSeq) return
const session = result?.session
if (!session?.id) {
const sessionId = session?.id
// / viewer lease
if (seq !== openSeq || disposed) {
if (sessionId) await safeLeave(stream.dockId, sessionId)
return
}
if (!sessionId) {
ui.toast('直播会话无效')
return
}
activeLive.id = stream.id
activeLive.dockId = stream.dockId
activeLive.sessionId = session.id
activeLive.sessionId = sessionId
activeLive.phase = session.phase || ''
activeLive.playUrl = ''
scheduleHeartbeat(result.leaseExpiresAt)
@ -236,15 +274,19 @@ async function openLive(stream) {
return
}
const play = await getLivePlayURL(stream.dockId, session.id)
if (seq !== openSeq || activeLive.sessionId !== session.id) return
const play = await getLivePlayURL(stream.dockId, sessionId)
if (seq !== openSeq || disposed || activeLive.sessionId !== sessionId) return
if (play?.playUrl && !play.playUrl.startsWith('fake://')) {
activeLive.playUrl = play.playUrl
} else {
ui.toast('控制面已验证,当前未配置可播放媒体流')
}
} catch (e) {
if (seq !== openSeq) return
if (seq !== openSeq || disposed) return
if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid(stream.id, stream.dockId)
return
}
await closeActiveLive()
ui.toast(e.message || '打开直播失败')
}
@ -285,8 +327,23 @@ watch(view, async (next, prev) => {
await load()
})
onMounted(load)
function onPageHide() {
disposed = true
openSeq += 1
const dockId = activeLive.dockId
const sessionId = activeLive.sessionId
resetActiveLive()
if (dockId && sessionId) leaveLiveKeepalive(dockId, sessionId)
}
onMounted(() => {
window.addEventListener('pagehide', onPageHide)
load()
})
onBeforeUnmount(() => {
window.removeEventListener('pagehide', onPageHide)
disposed = true
openSeq += 1
closeActiveLive()
})

9
src/views/MonitorView/MonitorView.vue

@ -127,6 +127,7 @@ const {
toggleLive,
fullscreenLive,
closeLive,
dispose: disposeLive,
stopLiveStream,
onPlayError,
} = useMonitorLive({
@ -135,6 +136,10 @@ const {
getLivePlayer: () => detailPanelRef.value?.livePlayerRef,
})
function onPageHide() {
disposeLive({ keepalive: true })
}
const filter = ref('all')
const search = ref('')
const controlExpanded = ref(false)
@ -655,6 +660,7 @@ function teardownMap() {
}
onMounted(async () => {
window.addEventListener('pagehide', onPageHide)
window.addEventListener('keydown', onGlobalKeydown)
try {
await devices.load()
@ -672,9 +678,10 @@ onMounted(async () => {
})
onUnmounted(() => {
window.removeEventListener('pagehide', onPageHide)
window.removeEventListener('keydown', onGlobalKeydown)
closeCommandProgress()
closeLive()
disposeLive()
teardownMap()
})

Loading…
Cancel
Save