From a2d875cebdf1925a7126b2026d0214e1e17db117 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 10 Nov 2026 05:50:55 +0800 Subject: [PATCH] fix(proxies): invalidate delays when switching profiles Clear cached delays and fence old measurements when the active profile changes. Refresh proxy history on page entry without forcing automatic measurements or expanding groups. --- Changelog.md | 1 + src/hooks/use-proxy-delay-state.ts | 3 +- src/pages/proxies.tsx | 6 +- src/providers/app-data-provider.tsx | 12 ++- src/services/delay.test.ts | 154 ++++++++++++++++++++++++---- src/services/delay.ts | 71 ++++++++++--- 6 files changed, 209 insertions(+), 38 deletions(-) diff --git a/Changelog.md b/Changelog.md index af0dc277d..b39adaba5 100644 --- a/Changelog.md +++ b/Changelog.md @@ -24,6 +24,7 @@
🐞 修复问题 +- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录 - 修复检测更新后又发布新版时,应用内更新失败 - 修复订阅响应过大导致内存占用过高,限制单次下载为 4 MiB - 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组 diff --git a/src/hooks/use-proxy-delay-state.ts b/src/hooks/use-proxy-delay-state.ts index 612b33d24..d2dfb5825 100644 --- a/src/hooks/use-proxy-delay-state.ts +++ b/src/hooks/use-proxy-delay-state.ts @@ -90,8 +90,7 @@ export function useProxyDelayState( const onDelay = useLockFn(async () => { if (!isInteractableMember(member)) return - setDelayState({ delay: -2, updatedAt: Date.now() }) - setDelayState(await delayManager.checkDelay(member, groupName, timeout)) + await delayManager.checkDelay(member, groupName, timeout) }) return { diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index 0a9e53063..e1eb4c72b 100644 --- a/src/pages/proxies.tsx +++ b/src/pages/proxies.tsx @@ -49,7 +49,11 @@ const ProxyPage = () => { >(null) const { clashConfig } = useClashConfigData() - const { refreshClashConfig } = useAppRefreshers() + const { refreshClashConfig, refreshProxy } = useAppRefreshers() + + useEffect(() => { + refreshProxy().catch((error) => showNotice.error(error)) + }, [refreshProxy]) const normalizedMode = clashConfig?.mode?.toLowerCase() const curMode = isMode(normalizedMode) ? normalizedMode : undefined diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index f66b85659..1e8edaa13 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { getBaseConfig, getRuleProviders, @@ -6,8 +6,10 @@ import { } from 'tauri-plugin-mihomo-api' import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash' +import { useProfiles } from '@/hooks/use-profiles' import { useVerge } from '@/hooks/use-verge' import { getProxyView, getSystemProxy } from '@/services/cmds' +import delayManager from '@/services/delay' import { useQuery } from '@/services/query-client' import { useRunState } from '@/store/app-store-context' import { resolveDisplayedMixedPort } from '@/utils/mixed-port' @@ -53,6 +55,8 @@ export const AppDataProvider = ({ const { verge } = useVerge() const { data: runtimeConfig } = useRuntimeConfig() const { clashInfo } = useClashInfo() + const { profiles } = useProfiles() + const currentProfile = profiles?.current const { data: proxyView, @@ -102,6 +106,12 @@ export const AppDataProvider = ({ const runningMode = runState?.mode const refreshProxy = useStableFn(_refetchProxyView) + useEffect(() => { + if (currentProfile === undefined) return + delayManager.setProfile(currentProfile) + void refreshProxy() + }, [currentProfile, refreshProxy]) + const refreshClashConfig = useStableFn(_refetchClashConfig) const refreshRules = useStableFn(_refetchRules) const refreshSysproxy = useStableFn(_refetchSysproxy) diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index a4d7c92fd..84291bfe9 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -1,3 +1,4 @@ +import { delayProxyByName } from 'tauri-plugin-mihomo-api' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' vi.mock('tauri-plugin-mihomo-api', () => ({ @@ -5,42 +6,54 @@ vi.mock('tauri-plugin-mihomo-api', () => ({ healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })), })) -import type { ResolvedProxyMember } from '@/types/proxy-view' +import type { InteractableProxyMember } from '@/types/proxy-view' import delayManager from './delay' -const node = (name: string) => - ({ - kind: 'node', - ref: { kind: 'node', name, recordId: `r:${name}` }, - node: { - recordId: `r:${name}`, - name, - history: [], - source: { kind: 'core', proxyName: name }, - }, - }) as unknown as ResolvedProxyMember - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) +const node = ( + name: string, +): Extract => ({ + kind: 'node', + ref: { kind: 'node', name, recordId: `r:${name}` }, + node: { + recordId: `r:${name}`, + name, + type: 'Shadowsocks', + history: [], + source: { kind: 'core', proxyName: name }, + udp: false, + xudp: false, + tfo: false, + mptcp: false, + smux: false, + }, +}) let settles = 0 let unsubscribe: () => void beforeEach(() => { + vi.useFakeTimers() + vi.mocked(delayProxyByName).mockReset().mockResolvedValue({ delay: 120 }) + delayManager.setProfile('initial') settles = 0 unsubscribe = delayManager.addGroupListener('g', () => { settles += 1 }) }) -afterEach(() => unsubscribe()) +afterEach(() => { + unsubscribe() + vi.useRealTimers() +}) describe('group delay completion', () => { test('notifies once after a batch settles', async () => { const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`)) - await delayManager.checkListDelay(proxies as never, 'g', 5000, 2) - await flush() + const checking = delayManager.checkListDelay(proxies, 'g', 5000, 2) + await vi.runAllTimersAsync() + await checking expect(settles).toBe(1) }) @@ -51,11 +64,114 @@ describe('group delay completion', () => { other += 1 }) - await delayManager.checkDelay(node('a') as never, 'g', 5000) - await flush() + const checking = delayManager.checkDelay(node('a'), 'g', 5000) + await vi.runAllTimersAsync() + await checking expect(settles).toBe(1) expect(other).toBe(0) stop() }) }) + +describe('profile delay isolation', () => { + test('drops cached and queued results on switch, exposing current history', async () => { + const listener = vi.fn() + delayManager.setListener('a', 'g', listener) + try { + delayManager.setDelay('a', 'g', 120) + const previousSnapshot = delayManager.groupDelays('g') + delayManager.setProfile('initial') + expect(delayManager.getDelay('a', 'g')).toBe(120) + + delayManager.setProfile('next') + const current = node('a') + current.node.history = [{ time: new Date().toISOString(), delay: 42 }] + expect(delayManager.getDelayUpdate('a', 'g')).toBeUndefined() + expect(delayManager.getDelayFix(current, 'g')).toBe(42) + expect(delayManager.groupDelays('g')).not.toBe(previousSnapshot) + + expect(listener).toHaveBeenCalledExactlyOnceWith({ + delay: -1, + updatedAt: 0, + }) + await vi.runAllTimersAsync() + expect(listener).toHaveBeenCalledOnce() + expect(settles).toBe(1) + } finally { + delayManager.removeListener('a', 'g') + } + }) + + test.each(['success', 'failure'] as const)( + 'ignores an old-profile single test completing with %s', + async (outcome) => { + let finish!: () => void + vi.mocked(delayProxyByName).mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finish = () => + outcome === 'success' + ? resolve({ delay: 900 }) + : reject(new Error('old-profile request failed')) + }), + ) + const oldCheck = delayManager.checkDelay(node('a'), 'g', 5000) + delayManager.setProfile('next') + const newCheck = delayManager.checkDelay(node('a'), 'g', 5000) + await vi.advanceTimersByTimeAsync(500) + await newCheck + await vi.advanceTimersByTimeAsync(1) + const settledBeforeOldReply = settles + + finish() + await vi.runAllTimersAsync() + await oldCheck + expect(delayManager.getDelay('a', 'g')).toBe(120) + expect(settles).toBe(settledBeforeOldReply) + }, + ) + + test('stops an old batch without settling a new batch for the same group', async () => { + let finishOld!: (result: { delay: number }) => void + let finishNew!: (result: { delay: number }) => void + vi.mocked(delayProxyByName) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishNew = resolve + }), + ) + + const oldBatch = delayManager.checkListDelay( + [node('a'), node('not-started')], + 'g', + 5000, + 1, + ) + delayManager.setProfile('next') + const newBatch = delayManager.checkListDelay([node('a')], 'g', 5000) + await vi.advanceTimersByTimeAsync(1) + const settledAfterSwitch = settles + + finishOld({ delay: 900 }) + await vi.advanceTimersByTimeAsync(500) + await oldBatch + expect(delayProxyByName).toHaveBeenCalledTimes(2) + expect(delayManager.getDelay('a', 'g')).toBe(-2) + expect(delayManager.getDelayUpdate('not-started', 'g')).toBeUndefined() + expect(settles).toBe(settledAfterSwitch) + + finishNew({ delay: 42 }) + await vi.runAllTimersAsync() + await newBatch + expect(delayManager.getDelay('a', 'g')).toBe(42) + expect(settles).toBe(settledAfterSwitch + 1) + }) +}) diff --git a/src/services/delay.ts b/src/services/delay.ts index 5758d41e8..d27660ad4 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -29,6 +29,8 @@ export interface DelayUpdate { const CACHE_TTL = 30 * 60 * 1000 class DelayManager { + private profile: string | null = null + private generation = 0 private cache = new Map() private urlMap = new Map() @@ -49,6 +51,33 @@ class DelayManager { private itemFlushScheduled = false private groupFlushScheduled = false + setProfile(profile: string | null) { + if (this.profile === profile) return + this.profile = profile + this.generation += 1 + this.cache.clear() + this.activeBatches.clear() + this.pendingItemUpdates.clear() + this.pendingGroupUpdates.clear() + this.groupSnapshots.clear() + this.groupSetSnapshots.clear() + + // Reset before a refreshed proxy snapshot can publish newer history. + for (const [key, listener] of this.listenerMap) { + try { + listener({ delay: -1, updatedAt: 0 }) + } catch (error) { + console.error( + `[DelayManager] Failed to reset delay listener: ${key}`, + error, + ) + } + } + for (const group of this.groupListenerMap.keys()) { + this.queueGroupNotification(group) + } + } + private scheduleOnNextFrame(run: () => void): void { if (typeof window !== 'undefined') { if (typeof window.requestAnimationFrame === 'function') { @@ -276,19 +305,21 @@ class DelayManager { member: InteractableProxyMember, group: string, timeout: number, - ): Promise { + ): Promise { + const generation = this.generation // Entry points own the pending mark; measureDelay only writes results. this.setDelay(member.ref.name, group, -2) - const update = await this.measureDelay(member, group, timeout) - this.queueGroupNotification(group) - return update + await this.measureDelay(member, group, timeout, generation) + if (generation === this.generation) this.queueGroupNotification(group) } private async measureDelay( member: InteractableProxyMember, group: string, timeout: number, - ): Promise { + generation: number, + ): Promise { + if (generation !== this.generation) return const name = member.ref.name const providerName = member.kind === 'node' ? providerNameOf(member.node) : undefined @@ -324,14 +355,18 @@ class DelayManager { const elapsed = elapsedTime debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`) - return this.setDelay(name, group, delay, { elapsed }) + if (generation === this.generation) { + this.setDelay(name, group, delay, { elapsed }) + } } catch (error) { await new Promise((resolve) => setTimeout(resolve, 500)) console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error) const delay = 1e6 // error const elapsed = Date.now() - startTime - return this.setDelay(name, group, delay, { elapsed }) + if (generation === this.generation) { + this.setDelay(name, group, delay, { elapsed }) + } } } @@ -341,6 +376,7 @@ class DelayManager { timeout: number, concurrency = 36, ) { + const generation = this.generation debugLog( `[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`, ) @@ -354,6 +390,7 @@ class DelayManager { const startTime = Date.now() const help = async (): Promise => { + if (generation !== this.generation) return const currMember = proxies[index++] if (!currMember) return const currName = currMember.ref.name @@ -367,13 +404,15 @@ class DelayManager { } // Do not reorder a list around the pointer while batch results are arriving. - await this.measureDelay(currMember, group, timeout) + await this.measureDelay(currMember, group, timeout, generation) } catch (error) { console.error( `[DelayManager] 批量测试单个代理出错,代理: ${currName}`, error, ) - this.setDelay(currName, group, 1e6) + if (generation === this.generation) { + this.setDelay(currName, group, 1e6) + } } return help() @@ -391,12 +430,14 @@ class DelayManager { await Promise.all(promiseList) } finally { // Always release the batch and notify; otherwise failures leave stale sort state. - const remaining = (this.activeBatches.get(group) ?? 1) - 1 - if (remaining > 0) { - this.activeBatches.set(group, remaining) - } else { - this.activeBatches.delete(group) - this.queueGroupNotification(group) + if (generation === this.generation) { + const remaining = (this.activeBatches.get(group) ?? 1) - 1 + if (remaining > 0) { + this.activeBatches.set(group, remaining) + } else { + this.activeBatches.delete(group) + this.queueGroupNotification(group) + } } } const totalTime = Date.now() - startTime