mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 11:37:29 +08:00
fix(connection): ignore probe failures during restart (#682)
This commit is contained in:
+15
-127
@@ -2,7 +2,7 @@
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api, { type ConnectionAwareRequestConfig } from '@/api'
|
||||
import api from '@/api'
|
||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
@@ -30,7 +30,8 @@ import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||
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 { loadMediaSources } from '@/composables/useMediaSources'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
@@ -159,6 +160,12 @@ const router = useRouter()
|
||||
const { initializePWA } = usePWA()
|
||||
const offlineStatus = useGlobalOfflineStatus()
|
||||
const { isRestarting: isSystemRestarting } = useSystemRestartStatus()
|
||||
const serverConnectionProbe = useServerConnectionProbe({
|
||||
isLoggedIn: isLogin,
|
||||
isRestarting: isSystemRestarting,
|
||||
offlineStatus,
|
||||
request: (path, config) => api.get(path, config),
|
||||
})
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -370,130 +377,12 @@ void router.isReady().then(() => {
|
||||
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
|
||||
|
||||
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(
|
||||
() => offlineStatus.connectionCheckRequestId.value,
|
||||
() => {
|
||||
if (isLogin.value) void probeServerConnection(true)
|
||||
if (isLogin.value) void serverConnectionProbe.probeServerConnection(true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -501,8 +390,7 @@ watch(
|
||||
() => offlineStatus.connectionStatus.value,
|
||||
status => {
|
||||
if (status !== 'online') return
|
||||
connectionProbeFailures = 0
|
||||
clearConnectionRetryTimer()
|
||||
serverConnectionProbe.resetAfterServerOnline()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1112,20 +1000,20 @@ onMounted(async () => {
|
||||
})
|
||||
// 启动心跳
|
||||
if (isLogin.value) {
|
||||
startHeartbeat()
|
||||
serverConnectionProbe.startHeartbeat()
|
||||
}
|
||||
|
||||
// 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。
|
||||
watch(isLogin, loggedIn => {
|
||||
if (loggedIn) {
|
||||
startHeartbeat()
|
||||
serverConnectionProbe.startHeartbeat()
|
||||
scheduleAuthenticatedStateInitialization()
|
||||
} else {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
serverConnectionProbe.stopHeartbeat()
|
||||
offlineStatus.markServerOnline()
|
||||
}
|
||||
})
|
||||
@@ -1140,7 +1028,7 @@ onUnmounted(() => {
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
serverConnectionProbe.stopHeartbeat()
|
||||
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
|
||||
prefersColorSchemeMediaQuery = null
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/composables/useMediaSubscribe.ts',
|
||||
'src/composables/useLlmProviderDirectory.ts',
|
||||
'src/composables/useOfflineStatus.ts',
|
||||
'src/composables/useServerConnectionProbe.ts',
|
||||
'src/composables/useTorrentFilter.ts',
|
||||
'src/layouts/default/components/OfflinePage.vue',
|
||||
'src/components/cards/SubscribeCard.vue',
|
||||
@@ -433,6 +434,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/composables/useServerConnectionProbe.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/layouts/default/components/OfflinePage.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
|
||||
Reference in New Issue
Block a user