fix(connection): ignore probe failures during restart (#682)

This commit is contained in:
InfinityPacer
2026-08-19 08:34:54 +08:00
committed by GitHub
parent 68ad3ec77c
commit dabdf7c5f7
4 changed files with 380 additions and 127 deletions
+15 -127
View File
@@ -2,7 +2,7 @@
import { usePreferredReducedMotion } from '@vueuse/core' import { usePreferredReducedMotion } from '@vueuse/core'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { ensureRenderComplete, removeEl } from './@core/utils/dom' import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api, { type ConnectionAwareRequestConfig } from '@/api' import api from '@/api'
import { useAuthStore, useGlobalSettingsStore } from '@/stores' import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n' import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n' import { SupportedLocale } from '@/types/i18n'
@@ -30,7 +30,8 @@ import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette
import { getDisplayImageUrl } from '@/utils/imageUtils' import { getDisplayImageUrl } from '@/utils/imageUtils'
import { normalizeThemeMaterialAccent } from '@/utils/glassColor' import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
import { configureApexChartsTheme } from '@/utils/apexCharts' import { configureApexChartsTheme } from '@/utils/apexCharts'
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import { useServerConnectionProbe } from '@/composables/useServerConnectionProbe'
import { useSystemRestartStatus } from '@/composables/useSystemRestart' import { useSystemRestartStatus } from '@/composables/useSystemRestart'
import { loadMediaSources } from '@/composables/useMediaSources' import { loadMediaSources } from '@/composables/useMediaSources'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
@@ -159,6 +160,12 @@ const router = useRouter()
const { initializePWA } = usePWA() const { initializePWA } = usePWA()
const offlineStatus = useGlobalOfflineStatus() const offlineStatus = useGlobalOfflineStatus()
const { isRestarting: isSystemRestarting } = useSystemRestartStatus() const { isRestarting: isSystemRestarting } = useSystemRestartStatus()
const serverConnectionProbe = useServerConnectionProbe({
isLoggedIn: isLogin,
isRestarting: isSystemRestarting,
offlineStatus,
request: (path, config) => api.get(path, config),
})
// 全局设置store // 全局设置store
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
@@ -370,130 +377,12 @@ void router.isReady().then(() => {
isInitialRouteReady.value = true isInitialRouteReady.value = true
}) })
let heartbeatInterval: number | null = null
let connectionRetryTimer: number | null = null
let connectionProbePromise: Promise<boolean> | null = null
let connectionProbeFailures = 0
let prefersColorSchemeMediaQuery: MediaQueryList | null = null let prefersColorSchemeMediaQuery: MediaQueryList | null = null
const SERVER_PROBE_TIMEOUT_MS = 8_000
const SERVER_PROBE_FAILURE_THRESHOLD = 2
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
/** 清除等待中的服务重连任务。 */
function clearConnectionRetryTimer() {
if (!connectionRetryTimer) return
window.clearTimeout(connectionRetryTimer)
connectionRetryTimer = null
}
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
if (!offlineStatus.browserOnline.value) return 'browser-offline'
const errorCode = (error as { code?: string } | null)?.code
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
return 'server-unreachable'
}
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
function scheduleConnectionRetry() {
clearConnectionRetryTimer()
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
connectionRetryTimer = window.setTimeout(() => {
connectionRetryTimer = null
void probeServerConnection()
}, SERVER_RETRY_DELAYS_MS[retryIndex])
}
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
async function probeServerConnection(showChecking = false): Promise<boolean> {
if (!isLogin.value) return false
if (connectionProbePromise) return connectionProbePromise
clearConnectionRetryTimer()
if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined)
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
const probePromise = (async () => {
try {
await api.get('system/ping', {
feedback: 'silent',
skipNavigationCancellation: true,
skipConnectionTracking: true,
timeout: SERVER_PROBE_TIMEOUT_MS,
} as ConnectionAwareRequestConfig)
connectionProbeFailures = 0
return true
} catch (error) {
if (!isLogin.value) {
offlineStatus.markServerOnline()
return false
}
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
connectionProbeFailures = 0
return true
}
connectionProbeFailures += 1
const failureReason = resolveProbeFailureReason(error)
// 重启期间服务不可达属预期行为,由重启进度弹窗承载反馈,不累计离线阈值。
if (isSystemRestarting.value) return false
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
offlineStatus.markServerOffline(failureReason)
} else {
offlineStatus.markConnectionChecking(failureReason)
}
scheduleConnectionRetry()
return false
}
})()
connectionProbePromise = probePromise
try {
return await probePromise
} finally {
if (connectionProbePromise === probePromise) connectionProbePromise = null
}
}
/** 启动即时服务探测和五分钟在线心跳。 */
function startHeartbeat() {
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
void probeServerConnection()
heartbeatInterval = window.setInterval(
async () => {
if (isLogin.value) await probeServerConnection()
},
5 * 60 * 1000,
)
}
/** 停止心跳和等待中的自动重连任务。 */
function stopHeartbeat() {
if (heartbeatInterval) {
window.clearInterval(heartbeatInterval)
heartbeatInterval = null
}
clearConnectionRetryTimer()
connectionProbeFailures = 0
}
watch( watch(
() => offlineStatus.connectionCheckRequestId.value, () => offlineStatus.connectionCheckRequestId.value,
() => { () => {
if (isLogin.value) void probeServerConnection(true) if (isLogin.value) void serverConnectionProbe.probeServerConnection(true)
}, },
) )
@@ -501,8 +390,7 @@ watch(
() => offlineStatus.connectionStatus.value, () => offlineStatus.connectionStatus.value,
status => { status => {
if (status !== 'online') return if (status !== 'online') return
connectionProbeFailures = 0 serverConnectionProbe.resetAfterServerOnline()
clearConnectionRetryTimer()
}, },
) )
@@ -1112,20 +1000,20 @@ onMounted(async () => {
}) })
// 启动心跳 // 启动心跳
if (isLogin.value) { if (isLogin.value) {
startHeartbeat() serverConnectionProbe.startHeartbeat()
} }
// 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。 // 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。
watch(isLogin, loggedIn => { watch(isLogin, loggedIn => {
if (loggedIn) { if (loggedIn) {
startHeartbeat() serverConnectionProbe.startHeartbeat()
scheduleAuthenticatedStateInitialization() scheduleAuthenticatedStateInitialization()
} else { } else {
if (authenticatedStateTimer) { if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer) window.clearTimeout(authenticatedStateTimer)
authenticatedStateTimer = null authenticatedStateTimer = null
} }
stopHeartbeat() serverConnectionProbe.stopHeartbeat()
offlineStatus.markServerOnline() offlineStatus.markServerOnline()
} }
}) })
@@ -1140,7 +1028,7 @@ onUnmounted(() => {
authenticatedStateTimer = null authenticatedStateTimer = null
} }
// 停止心跳 // 停止心跳
stopHeartbeat() serverConnectionProbe.stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange) prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
prefersColorSchemeMediaQuery = null prefersColorSchemeMediaQuery = null
document.removeEventListener('visibilitychange', handleVisibilityThemeSync) document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
@@ -0,0 +1,211 @@
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
import { useServerConnectionProbe } from '@/composables/useServerConnectionProbe'
import { ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
function createOfflineStatus() {
const browserOnline = ref(true)
const connectionReason = ref<ConnectionFailureReason | null>(null)
const serverSuccessSequence = ref(0)
const markConnectionChecking = vi.fn((reason?: ConnectionFailureReason) => {
if (reason) connectionReason.value = reason
})
const markServerOffline = vi.fn()
const markServerOnline = vi.fn()
const offlineStatus = {
browserOnline,
connectionReason,
markConnectionChecking,
markServerOffline,
markServerOnline,
serverSuccessSequence,
} as unknown as ReturnType<typeof useGlobalOfflineStatus>
return { browserOnline, offlineStatus, serverSuccessSequence }
}
describe('useServerConnectionProbe', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('未登录时不探测,并用固定静默选项去重并发 ping', async () => {
const isLoggedIn = ref(false)
const pendingRequest = deferred<unknown>()
const request = vi.fn(() => pendingRequest.promise)
const probe = useServerConnectionProbe({
isLoggedIn,
isRestarting: ref(false),
offlineStatus: createOfflineStatus().offlineStatus,
request,
})
await expect(probe.probeServerConnection()).resolves.toBe(false)
expect(request).not.toHaveBeenCalled()
isLoggedIn.value = true
const firstProbe = probe.probeServerConnection()
const concurrentProbe = probe.probeServerConnection(true)
expect(request).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenCalledWith('system/ping', {
feedback: 'silent',
skipNavigationCancellation: true,
skipConnectionTracking: true,
timeout: 8_000,
})
pendingRequest.resolve({})
await expect(Promise.all([firstProbe, concurrentProbe])).resolves.toEqual([true, true])
})
it('普通失败先进入 checking,第二次失败才离线并按退避间隔重试', async () => {
const { offlineStatus } = createOfflineStatus()
const request = vi.fn().mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }))
const probe = useServerConnectionProbe({
isLoggedIn: ref(true),
isRestarting: ref(false),
offlineStatus,
request,
})
await expect(probe.probeServerConnection(true)).resolves.toBe(false)
expect(offlineStatus.markConnectionChecking).toHaveBeenNthCalledWith(1, undefined)
expect(offlineStatus.markConnectionChecking).toHaveBeenNthCalledWith(2, 'timeout')
expect(offlineStatus.markServerOffline).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(1)
await expect(probe.probeServerConnection()).resolves.toBe(false)
expect(offlineStatus.markServerOffline).toHaveBeenCalledWith('timeout')
expect(request).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(4_999)
expect(request).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(request).toHaveBeenCalledTimes(3)
probe.stopHeartbeat()
})
it('按浏览器状态区分离线原因,并在任意接口已成功时忽略迟到失败', async () => {
const { browserOnline, offlineStatus, serverSuccessSequence } = createOfflineStatus()
const pendingRequest = deferred<unknown>()
const request = vi.fn(() => pendingRequest.promise)
const probe = useServerConnectionProbe({
isLoggedIn: ref(true),
isRestarting: ref(false),
offlineStatus,
request,
})
browserOnline.value = false
const pendingProbe = probe.probeServerConnection()
serverSuccessSequence.value += 1
pendingRequest.reject(new Error('offline'))
await expect(pendingProbe).resolves.toBe(true)
expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled()
expect(offlineStatus.markServerOffline).not.toHaveBeenCalled()
request.mockRejectedValueOnce(new Error('offline'))
await expect(probe.probeServerConnection()).resolves.toBe(false)
expect(offlineStatus.markConnectionChecking).toHaveBeenCalledWith('browser-offline')
probe.stopHeartbeat()
})
it('探测期间退出登录时恢复在线状态且不安排重试', async () => {
const isLoggedIn = ref(true)
const pendingRequest = deferred<unknown>()
const { offlineStatus } = createOfflineStatus()
const probe = useServerConnectionProbe({
isLoggedIn,
isRestarting: ref(false),
offlineStatus,
request: () => pendingRequest.promise,
})
const pendingProbe = probe.probeServerConnection()
isLoggedIn.value = false
pendingRequest.reject(new Error('signed out'))
await expect(pendingProbe).resolves.toBe(false)
expect(offlineStatus.markServerOnline).toHaveBeenCalledOnce()
expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('重启期间的失败不提示、不重试,也不累计到重启后的离线阈值', async () => {
const isRestarting = ref(true)
const { offlineStatus } = createOfflineStatus()
const request = vi.fn().mockRejectedValue(new Error('restarting'))
const probe = useServerConnectionProbe({
isLoggedIn: ref(true),
isRestarting,
offlineStatus,
request,
})
await probe.probeServerConnection()
await probe.probeServerConnection()
expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled()
expect(offlineStatus.markServerOffline).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
isRestarting.value = false
await probe.probeServerConnection()
expect(offlineStatus.markConnectionChecking).toHaveBeenCalledWith('server-unreachable')
expect(offlineStatus.markServerOffline).not.toHaveBeenCalled()
probe.stopHeartbeat()
})
it('启动时立即探测并按五分钟心跳运行,在线恢复和停止都会清理等待任务', async () => {
const request = vi.fn().mockResolvedValue({})
const probe = useServerConnectionProbe({
isLoggedIn: ref(true),
isRestarting: ref(false),
offlineStatus: createOfflineStatus().offlineStatus,
request,
})
probe.startHeartbeat()
await Promise.resolve()
expect(request).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
expect(request).toHaveBeenCalledTimes(2)
probe.startHeartbeat()
await Promise.resolve()
expect(request).toHaveBeenCalledTimes(3)
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(0)
request.mockRejectedValueOnce(new Error('offline'))
await expect(probe.probeServerConnection()).resolves.toBe(false)
expect(vi.getTimerCount()).toBe(2)
probe.resetAfterServerOnline()
expect(vi.getTimerCount()).toBe(1)
probe.stopHeartbeat()
expect(vi.getTimerCount()).toBe(0)
})
})
+147
View File
@@ -0,0 +1,147 @@
import type { ConnectionAwareRequestConfig } from '@/api'
import type { ConnectionFailureReason, useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import { toValue, type MaybeRefOrGetter } from 'vue'
interface ServerConnectionProbeOptions {
/** 当前会话是否已具备访问 MoviePilot API 的登录态。 */
isLoggedIn: MaybeRefOrGetter<unknown>
/** 系统是否处于主动重启流程。 */
isRestarting: MaybeRefOrGetter<boolean>
/** 全局连接状态及其唯一写入操作。 */
offlineStatus: ReturnType<typeof useGlobalOfflineStatus>
/** 使用静默连接选项请求 MoviePilot API。 */
request: (path: string, config: ConnectionAwareRequestConfig) => Promise<unknown>
}
const SERVER_PROBE_TIMEOUT_MS = 8_000
const SERVER_PROBE_FAILURE_THRESHOLD = 2
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
const SERVER_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
/** 编排 MoviePilot 服务探测、退避重试和在线心跳。 */
export function useServerConnectionProbe(options: ServerConnectionProbeOptions) {
let heartbeatInterval: number | null = null
let connectionRetryTimer: number | null = null
let connectionProbePromise: Promise<boolean> | null = null
let connectionProbeFailures = 0
/** 清除等待中的服务重连任务。 */
function clearConnectionRetryTimer() {
if (!connectionRetryTimer) return
window.clearTimeout(connectionRetryTimer)
connectionRetryTimer = null
}
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
if (!options.offlineStatus.browserOnline.value) return 'browser-offline'
const errorCode = (error as { code?: string } | null)?.code
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
return 'server-unreachable'
}
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
function scheduleConnectionRetry() {
clearConnectionRetryTimer()
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
connectionRetryTimer = window.setTimeout(() => {
connectionRetryTimer = null
void probeServerConnection()
}, SERVER_RETRY_DELAYS_MS[retryIndex])
}
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
async function probeServerConnection(showChecking = false): Promise<boolean> {
if (!toValue(options.isLoggedIn)) return false
if (connectionProbePromise) return connectionProbePromise
clearConnectionRetryTimer()
if (showChecking) {
options.offlineStatus.markConnectionChecking(options.offlineStatus.connectionReason.value ?? undefined)
}
const successSequenceAtProbeStart = options.offlineStatus.serverSuccessSequence.value
const probePromise = (async () => {
try {
await options.request('system/ping', {
feedback: 'silent',
skipNavigationCancellation: true,
skipConnectionTracking: true,
timeout: SERVER_PROBE_TIMEOUT_MS,
})
connectionProbeFailures = 0
return true
} catch (error) {
if (!toValue(options.isLoggedIn)) {
options.offlineStatus.markServerOnline()
return false
}
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
if (options.offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
connectionProbeFailures = 0
return true
}
// 重启期间服务不可达属预期行为,由重启进度弹窗承载反馈,不累计离线阈值。
if (toValue(options.isRestarting)) return false
connectionProbeFailures += 1
const failureReason = resolveProbeFailureReason(error)
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
options.offlineStatus.markServerOffline(failureReason)
} else {
options.offlineStatus.markConnectionChecking(failureReason)
}
scheduleConnectionRetry()
return false
}
})()
connectionProbePromise = probePromise
try {
return await probePromise
} finally {
if (connectionProbePromise === probePromise) connectionProbePromise = null
}
}
/** 清除失败计数和等待中的重试,用于任意 API 已证明服务恢复在线的场景。 */
function resetAfterServerOnline() {
connectionProbeFailures = 0
clearConnectionRetryTimer()
}
/** 启动即时服务探测和五分钟在线心跳。 */
function startHeartbeat() {
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
void probeServerConnection()
heartbeatInterval = window.setInterval(() => {
if (toValue(options.isLoggedIn)) void probeServerConnection()
}, SERVER_HEARTBEAT_INTERVAL_MS)
}
/** 停止心跳、重试和失败计数。 */
function stopHeartbeat() {
if (heartbeatInterval) {
window.clearInterval(heartbeatInterval)
heartbeatInterval = null
}
resetAfterServerOnline()
}
return {
probeServerConnection,
resetAfterServerOnline,
startHeartbeat,
stopHeartbeat,
}
}
+7
View File
@@ -337,6 +337,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/composables/useMediaSubscribe.ts', 'src/composables/useMediaSubscribe.ts',
'src/composables/useLlmProviderDirectory.ts', 'src/composables/useLlmProviderDirectory.ts',
'src/composables/useOfflineStatus.ts', 'src/composables/useOfflineStatus.ts',
'src/composables/useServerConnectionProbe.ts',
'src/composables/useTorrentFilter.ts', 'src/composables/useTorrentFilter.ts',
'src/layouts/default/components/OfflinePage.vue', 'src/layouts/default/components/OfflinePage.vue',
'src/components/cards/SubscribeCard.vue', 'src/components/cards/SubscribeCard.vue',
@@ -433,6 +434,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 90, lines: 90,
statements: 90, statements: 90,
}, },
'src/composables/useServerConnectionProbe.ts': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/layouts/default/components/OfflinePage.vue': { 'src/layouts/default/components/OfflinePage.vue': {
branches: 75, branches: 75,
functions: 80, functions: 80,