mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-10 12:22:07 +08:00
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:
@@ -24,6 +24,7 @@
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录
|
||||
- 修复检测更新后又发布新版时,应用内更新失败
|
||||
- 修复订阅响应过大导致内存占用过高,限制单次下载为 4 MiB
|
||||
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user