diff --git a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts index 86f36e92..f79fc912 100644 --- a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts +++ b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts @@ -4,20 +4,20 @@ import { getTaskPollBackoffMs, getTaskForegroundRefreshEnabled, getTaskForegroundRefreshDelayMs, -} from '@/shared/task-progress-config' -import { createCategorizedTimers } from '@/shared/utils/categorized-timers' +} from '../task-progress-config.ts' +import { createCategorizedTimers } from '../utils/categorized-timers.ts' import { createTaskPollingBaseline, type TaskPollingBaseline, -} from '@/shared/task-polling-baseline' +} from '../task-polling-baseline.ts' import { createProgressResponseCache, type ProgressResponseCache, -} from '@/shared/progress-response-cache' +} from '../progress-response-cache.ts' import { createTaskPollingCoordinator, type TaskPollingCoordinator, -} from '@/shared/task-polling-coordinator' +} from '../task-polling-coordinator.ts' /** * 通用任务进度轮询组合式函数。 @@ -59,8 +59,11 @@ export interface TaskProgressLoopOptions { * 若多个任务同一轮到达终态,会被分别回调。 */ onTerminal?: (taskId: number, detail: TDetail | undefined, status: string) => void | Promise - /** 轮询周期失败时的回调;默认静默 */ - onError?: (error: unknown) => void + /** + * 轮询周期失败时的回调;默认静默。 + * 第二个参数 attempt 为连续失败计数(0 起,成功后归零),供上层决定退避策略。 + */ + onError?: (error: unknown, attempt?: number) => void /** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s) */ getIntervalMs?: () => number /** @@ -138,6 +141,7 @@ export function useTaskProgressLoop( const inFlight = ref(false) let pollTimer: number | null = null let disposed = false + let failureCount = 0 function persist() { writeIdsToStorage(options.storageKey, taskIds.value) @@ -204,6 +208,7 @@ export function useTaskProgressLoop( } } taskStatuses.value = nextStatuses + failureCount = 0 for (const event of terminalEvents) { coordinator?.markTerminal(event.taskId) remove(event.taskId) @@ -214,7 +219,11 @@ export function useTaskProgressLoop( } } } catch (error) { - options.onError?.(error) + if (!disposed) { + const attempt = failureCount + failureCount = Math.min(failureCount + 1, 100) + options.onError?.(error, attempt) + } } finally { inFlight.value = false if (baseline) options.onBaseline?.(baseline) diff --git a/frontend-vue/tests/polling-failure-count.test.ts b/frontend-vue/tests/polling-failure-count.test.ts new file mode 100644 index 00000000..9e3f39d3 --- /dev/null +++ b/frontend-vue/tests/polling-failure-count.test.ts @@ -0,0 +1,179 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' + +interface LoopCallbacks { + onError?: (error: unknown, attempt?: number) => void + fetchImpl?: () => Promise<{ items: unknown[] }> +} + +function setupWindow() { + const storage = new Map() + ;(globalThis as Record).window = { + localStorage: { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(k) }, + }, + setTimeout: (fn: () => void, ms: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: unknown) => globalThis.clearTimeout(id as number), + setInterval: (fn: () => void, ms: number) => globalThis.setInterval(fn, ms), + clearInterval: (id: unknown) => globalThis.clearInterval(id as number), + } +} + +let scopeCounter = 0 + +function tick() { + return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) +} + +/** + * 构造轮询循环并等待第一轮(构造时因任务从空到非空自动触发) + * 完成后返回;后续手动 refreshOnce 的计数因此从稳定起点开始。 + */ +async function makeLoop(callbacks: LoopCallbacks) { + const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ + scope: `failure-count-${scopeCounter++}`, + fetchProgress: callbacks.fetchImpl ?? (async () => ({ items: [] })), + extractTaskId: (detail) => detail?.taskId, + extractStatus: (detail) => detail?.status, + getIntervalMs: () => 60000, + onError: callbacks.onError, + }) + loop.reset([1]) + await tick() + return loop +} + +test('test_failure_count_increments', async () => { + setupWindow() + const attempts: number[] = [] + const loop = await makeLoop({ + onError: (_e, attempt) => attempts.push(attempt ?? -1), + fetchImpl: async () => { throw new Error('net down') }, + }) + await loop.refreshOnce() + await loop.refreshOnce() + // [首轮自动刷新(0), 手动第1次(1), 手动第2次(2)] + assert.deepEqual(attempts, [0, 1, 2], '连续失败 attempt 递增') + loop.dispose() +}) + +test('test_success_resets_count', async () => { + setupWindow() + const attempts: number[] = [] + let fail = true + const loop = await makeLoop({ + onError: (_e, attempt) => attempts.push(attempt ?? -1), + fetchImpl: async () => { + if (fail) throw new Error('net down') + return { items: [] } + }, + }) + await loop.refreshOnce() + await loop.refreshOnce() + fail = false + await loop.refreshOnce() + fail = true + await loop.refreshOnce() + assert.deepEqual(attempts, [0, 1, 2, 0], '成功后计数归零重新从 0 起') + loop.dispose() +}) + +test('test_any_response_resets', async () => { + setupWindow() + const attempts: number[] = [] + let fail = true + const loop = await makeLoop({ + onError: (_e, attempt) => attempts.push(attempt ?? -1), + fetchImpl: async () => { + if (fail) throw new Error('net down') + return { items: [] } + }, + }) + fail = false + await loop.refreshOnce() + assert.deepEqual(attempts, [0], '收到响应后失败计数归零') + loop.dispose() +}) + +test('test_on_error_receives_attempt', async () => { + setupWindow() + const received: Array<{ error: unknown; attempt?: number }> = [] + const loop = await makeLoop({ + onError: (error, attempt) => received.push({ error, attempt }), + fetchImpl: async () => { throw new Error('boom') }, + }) + assert.equal(received.length, 1) + assert.equal(received[0].attempt, 0) + assert.match((received[0].error as Error).message, /boom/) + loop.dispose() +}) + +test('test_initial_count_zero', async () => { + setupWindow() + const attempts: number[] = [] + const loop = await makeLoop({ + onError: (_e, attempt) => attempts.push(attempt ?? -1), + fetchImpl: async () => { throw new Error('x') }, + }) + assert.equal(attempts[0], 0, '首次失败 attempt 为 0') + loop.dispose() +}) + +test('test_interleaved_fail_success', async () => { + setupWindow() + const attempts: number[] = [] + let fail = true + const loop = await makeLoop({ + onError: (_e, attempt) => attempts.push(attempt ?? -1), + fetchImpl: async () => { + if (fail) throw new Error('net') + return { items: [] } + }, + }) + for (let i = 0; i < 5; i++) { + fail = i % 2 === 0 + await loop.refreshOnce() + } + // [构造轮首轮失败(0), i=0 失败(1), i=2 失败(0), i=4 失败(0)] + assert.deepEqual(attempts, [0, 1, 0, 0], '失败成功交错,每次失败从归零后的计数起') + loop.dispose() +}) + +test('test_count_bounded', async () => { + setupWindow() + const attempts: number[] = [] + const loop = await makeLoop({ + onError: (_e, attempt) => attempts.push(attempt ?? -1), + fetchImpl: async () => { throw new Error('net') }, + }) + for (let i = 0; i < 150; i++) { + await loop.refreshOnce() + } + assert.equal(attempts.length, 151) + assert.equal(attempts[100], 100) + const tail = attempts.slice(101) + assert.ok(tail.every((a) => a === 100), '超过上限后 attempt 封顶不再增长') + loop.dispose() +}) + +test('test_dispose_clears_count', async () => { + setupWindow() + let onErrorCalls = 0 + let fetchCalls = 0 + const loop = await makeLoop({ + onError: () => { onErrorCalls += 1 }, + fetchImpl: async () => { + fetchCalls += 1 + throw new Error('net') + }, + }) + await loop.refreshOnce() + assert.equal(onErrorCalls, 2) + loop.dispose() + await loop.refreshOnce() + assert.equal(fetchCalls, 2, 'dispose 后不再发请求') + assert.equal(onErrorCalls, 2, 'dispose 后不再上报错误') +})