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.
This commit is contained in:
Tunglies
2026-11-10 05:50:55 +08:00
parent 9d0bee4af9
commit a2d875cebd
6 changed files with 209 additions and 38 deletions
+1
View File
@@ -24,6 +24,7 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录
- 修复检测更新后又发布新版时,应用内更新失败
- 修复订阅响应过大导致内存占用过高,限制单次下载为 4 MiB
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
+1 -2
View File
@@ -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 {
+5 -1
View File
@@ -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
+11 -1
View File
@@ -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)
+135 -19
View File
@@ -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<InteractableProxyMember, { kind: 'node' }> => ({
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)
})
})
+56 -15
View File
@@ -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<string, DelayUpdate>()
private urlMap = new Map<string, string>()
@@ -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<DelayUpdate> {
): Promise<void> {
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<DelayUpdate> {
generation: number,
): Promise<void> {
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<void> => {
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