Compare commits

..
Author SHA1 Message Date
Tunglies 2db9da5d06 test(proxies): narrow delay regressions to reported failures
Remove global fake timers and deferred request scaffolding. Keep only profile-switch cache isolation and shared-key listener cleanup regressions.
2026-11-10 12:15:39 +08:00
Tunglies 371ca6e907 test(proxies): keep focused delay regression coverage
Reduce added delay coverage to profile-switch isolation and shared-key subscriptions. Append the changelog entry after existing general fixes.
2026-11-10 05:58:17 +08:00
Tunglies b02fb26c0c fix(proxies): retain independent delay listeners
Notify every mounted occurrence sharing a delay key and scope cleanup to its registered listener. Cover pending results, profile reset, and either occurrence unmounting without restoring direct unfenced updates.
2026-11-10 05:55:28 +08:00
Tunglies a2d875cebd 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.
2026-11-10 05:50:55 +08:00
6 changed files with 163 additions and 56 deletions
+1
View File
@@ -29,6 +29,7 @@
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组 - 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用 - 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」 - 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录
**🍎 macOS** **🍎 macOS**
+2 -6
View File
@@ -48,10 +48,7 @@ export function useProxyDelayState(
useEffect(() => { useEffect(() => {
if (isPreset) return if (isPreset) return
delayManager.setListener(name, groupName, setDelayState) return delayManager.addListener(name, groupName, setDelayState)
return () => {
delayManager.removeListener(name, groupName)
}
}, [name, groupName, isPreset]) }, [name, groupName, isPreset])
const updateDelay = useCallback(() => { const updateDelay = useCallback(() => {
@@ -90,8 +87,7 @@ export function useProxyDelayState(
const onDelay = useLockFn(async () => { const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return if (!isInteractableMember(member)) return
setDelayState({ delay: -2, updatedAt: Date.now() }) await delayManager.checkDelay(member, groupName, timeout)
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
}) })
return { return {
+5 -1
View File
@@ -49,7 +49,11 @@ const ProxyPage = () => {
>(null) >(null)
const { clashConfig } = useClashConfigData() const { clashConfig } = useClashConfigData()
const { refreshClashConfig } = useAppRefreshers() const { refreshClashConfig, refreshProxy } = useAppRefreshers()
useEffect(() => {
refreshProxy().catch((error) => showNotice.error(error))
}, [refreshProxy])
const normalizedMode = clashConfig?.mode?.toLowerCase() const normalizedMode = clashConfig?.mode?.toLowerCase()
const curMode = isMode(normalizedMode) ? normalizedMode : undefined 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 { import {
getBaseConfig, getBaseConfig,
getRuleProviders, getRuleProviders,
@@ -6,8 +6,10 @@ import {
} from 'tauri-plugin-mihomo-api' } from 'tauri-plugin-mihomo-api'
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash' import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { getProxyView, getSystemProxy } from '@/services/cmds' import { getProxyView, getSystemProxy } from '@/services/cmds'
import delayManager from '@/services/delay'
import { useQuery } from '@/services/query-client' import { useQuery } from '@/services/query-client'
import { useRunState } from '@/store/app-store-context' import { useRunState } from '@/store/app-store-context'
import { resolveDisplayedMixedPort } from '@/utils/mixed-port' import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
@@ -53,6 +55,8 @@ export const AppDataProvider = ({
const { verge } = useVerge() const { verge } = useVerge()
const { data: runtimeConfig } = useRuntimeConfig() const { data: runtimeConfig } = useRuntimeConfig()
const { clashInfo } = useClashInfo() const { clashInfo } = useClashInfo()
const { profiles } = useProfiles()
const currentProfile = profiles?.current
const { const {
data: proxyView, data: proxyView,
@@ -102,6 +106,12 @@ export const AppDataProvider = ({
const runningMode = runState?.mode const runningMode = runState?.mode
const refreshProxy = useStableFn(_refetchProxyView) const refreshProxy = useStableFn(_refetchProxyView)
useEffect(() => {
if (currentProfile === undefined) return
delayManager.setProfile(currentProfile)
void refreshProxy()
}, [currentProfile, refreshProxy])
const refreshClashConfig = useStableFn(_refetchClashConfig) const refreshClashConfig = useStableFn(_refetchClashConfig)
const refreshRules = useStableFn(_refetchRules) const refreshRules = useStableFn(_refetchRules)
const refreshSysproxy = useStableFn(_refetchSysproxy) const refreshSysproxy = useStableFn(_refetchSysproxy)
+55 -8
View File
@@ -5,26 +5,32 @@ vi.mock('tauri-plugin-mihomo-api', () => ({
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })), healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
})) }))
import type { ResolvedProxyMember } from '@/types/proxy-view' import type { InteractableProxyMember } from '@/types/proxy-view'
import delayManager from './delay' import delayManager from './delay'
const node = (name: string) => const node = (
({ name: string,
): Extract<InteractableProxyMember, { kind: 'node' }> => ({
kind: 'node', kind: 'node',
ref: { kind: 'node', name, recordId: `r:${name}` }, ref: { kind: 'node', name, recordId: `r:${name}` },
node: { node: {
recordId: `r:${name}`, recordId: `r:${name}`,
name, name,
type: 'Shadowsocks',
history: [], history: [],
source: { kind: 'core', proxyName: name }, source: { kind: 'core', proxyName: name },
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
}, },
}) as unknown as ResolvedProxyMember })
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
let settles = 0 let settles = 0
let unsubscribe: () => void let unsubscribe: () => void
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
beforeEach(() => { beforeEach(() => {
settles = 0 settles = 0
@@ -39,7 +45,7 @@ describe('group delay completion', () => {
test('notifies once after a batch settles', async () => { test('notifies once after a batch settles', async () => {
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`)) const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
await delayManager.checkListDelay(proxies as never, 'g', 5000, 2) await delayManager.checkListDelay(proxies, 'g', 5000, 2)
await flush() await flush()
expect(settles).toBe(1) expect(settles).toBe(1)
@@ -51,7 +57,7 @@ describe('group delay completion', () => {
other += 1 other += 1
}) })
await delayManager.checkDelay(node('a') as never, 'g', 5000) await delayManager.checkDelay(node('a'), 'g', 5000)
await flush() await flush()
expect(settles).toBe(1) expect(settles).toBe(1)
@@ -59,3 +65,44 @@ describe('group delay completion', () => {
stop() stop()
}) })
}) })
describe('profile delay isolation', () => {
test('notifies both shared-key occurrences and preserves the surviving subscription', async () => {
const first = vi.fn()
const second = vi.fn()
const stopFirst = delayManager.addListener('a', 'g', first)
const stopSecond = delayManager.addListener('a', 'g', second)
try {
delayManager.setDelay('a', 'g', -2)
await flush()
stopFirst()
delayManager.setDelay('a', 'g', 120)
await flush()
expect(first.mock.calls.map(([update]) => update.delay)).toEqual([-2])
expect(second.mock.calls.map(([update]) => update.delay)).toEqual([
-2, 120,
])
} finally {
stopFirst()
stopSecond()
}
})
test('clears cached delays and fences an old batch across a profile switch', async () => {
delayManager.setProfile('initial')
delayManager.setDelay('cached', 'g', 900)
const oldBatch = delayManager.checkListDelay(
[node('a'), node('not-started')],
'g',
5000,
1,
)
delayManager.setProfile('next')
expect(delayManager.getDelayUpdate('cached', 'g')).toBeUndefined()
delayManager.setDelay('a', 'g', 42)
await oldBatch
await flush()
expect(delayManager.getDelay('a', 'g')).toBe(42)
expect(delayManager.getDelayUpdate('not-started', 'g')).toBeUndefined()
})
})
+67 -18
View File
@@ -29,10 +29,12 @@ export interface DelayUpdate {
const CACHE_TTL = 30 * 60 * 1000 const CACHE_TTL = 30 * 60 * 1000
class DelayManager { class DelayManager {
private profile: string | null = null
private generation = 0
private cache = new Map<string, DelayUpdate>() private cache = new Map<string, DelayUpdate>()
private urlMap = new Map<string, string>() private urlMap = new Map<string, string>()
private listenerMap = new Map<string, (update: DelayUpdate) => void>() private listenerMap = new Map<string, Set<(update: DelayUpdate) => void>>()
private groupListenerMap = new Map<string, Set<() => void>>() private groupListenerMap = new Map<string, Set<() => void>>()
// Consumers compare snapshot identity; replace it only when the group settles. // Consumers compare snapshot identity; replace it only when the group settles.
@@ -49,6 +51,35 @@ class DelayManager {
private itemFlushScheduled = false private itemFlushScheduled = false
private groupFlushScheduled = 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, listeners] of this.listenerMap) {
for (const listener of [...listeners]) {
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 { private scheduleOnNextFrame(run: () => void): void {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
if (typeof window.requestAnimationFrame === 'function') { if (typeof window.requestAnimationFrame === 'function') {
@@ -74,10 +105,11 @@ class DelayManager {
this.pendingItemUpdates = new Map() this.pendingItemUpdates = new Map()
updates.forEach((queue, key) => { updates.forEach((queue, key) => {
const listener = this.listenerMap.get(key) const listeners = this.listenerMap.get(key)
if (!listener) return if (!listeners) return
queue.forEach((update) => { queue.forEach((update) => {
for (const listener of [...listeners]) {
try { try {
listener(update) listener(update)
} catch (error) { } catch (error) {
@@ -86,6 +118,7 @@ class DelayManager {
error, error,
) )
} }
}
}) })
}) })
}) })
@@ -170,18 +203,22 @@ class DelayManager {
return url || 'http://cp.cloudflare.com/generate_204' return url || 'http://cp.cloudflare.com/generate_204'
} }
setListener( addListener(
name: string, name: string,
group: string, group: string,
listener: (update: DelayUpdate) => void, listener: (update: DelayUpdate) => void,
) { ): () => void {
const key = hashKey(name, group) const key = hashKey(name, group)
this.listenerMap.set(key, listener) const listeners = this.listenerMap.get(key) ?? new Set()
} listeners.add(listener)
this.listenerMap.set(key, listeners)
removeListener(name: string, group: string) { return () => {
const key = hashKey(name, group) const current = this.listenerMap.get(key)
this.listenerMap.delete(key) if (!current) return
current.delete(listener)
if (current.size === 0) this.listenerMap.delete(key)
}
} }
/** Multiple views may subscribe independently; notifications occur only after settle. */ /** Multiple views may subscribe independently; notifications occur only after settle. */
@@ -276,19 +313,21 @@ class DelayManager {
member: InteractableProxyMember, member: InteractableProxyMember,
group: string, group: string,
timeout: number, timeout: number,
): Promise<DelayUpdate> { ): Promise<void> {
const generation = this.generation
// Entry points own the pending mark; measureDelay only writes results. // Entry points own the pending mark; measureDelay only writes results.
this.setDelay(member.ref.name, group, -2) this.setDelay(member.ref.name, group, -2)
const update = await this.measureDelay(member, group, timeout) await this.measureDelay(member, group, timeout, generation)
this.queueGroupNotification(group) if (generation === this.generation) this.queueGroupNotification(group)
return update
} }
private async measureDelay( private async measureDelay(
member: InteractableProxyMember, member: InteractableProxyMember,
group: string, group: string,
timeout: number, timeout: number,
): Promise<DelayUpdate> { generation: number,
): Promise<void> {
if (generation !== this.generation) return
const name = member.ref.name const name = member.ref.name
const providerName = const providerName =
member.kind === 'node' ? providerNameOf(member.node) : undefined member.kind === 'node' ? providerNameOf(member.node) : undefined
@@ -324,14 +363,18 @@ class DelayManager {
const elapsed = elapsedTime const elapsed = elapsedTime
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`) debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
return this.setDelay(name, group, delay, { elapsed }) if (generation === this.generation) {
this.setDelay(name, group, delay, { elapsed })
}
} catch (error) { } catch (error) {
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error) console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
const delay = 1e6 // error const delay = 1e6 // error
const elapsed = Date.now() - startTime const elapsed = Date.now() - startTime
return this.setDelay(name, group, delay, { elapsed }) if (generation === this.generation) {
this.setDelay(name, group, delay, { elapsed })
}
} }
} }
@@ -341,6 +384,7 @@ class DelayManager {
timeout: number, timeout: number,
concurrency = 36, concurrency = 36,
) { ) {
const generation = this.generation
debugLog( debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`, `[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
) )
@@ -354,6 +398,7 @@ class DelayManager {
const startTime = Date.now() const startTime = Date.now()
const help = async (): Promise<void> => { const help = async (): Promise<void> => {
if (generation !== this.generation) return
const currMember = proxies[index++] const currMember = proxies[index++]
if (!currMember) return if (!currMember) return
const currName = currMember.ref.name const currName = currMember.ref.name
@@ -367,14 +412,16 @@ class DelayManager {
} }
// Do not reorder a list around the pointer while batch results are arriving. // 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) { } catch (error) {
console.error( console.error(
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`, `[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error, error,
) )
if (generation === this.generation) {
this.setDelay(currName, group, 1e6) this.setDelay(currName, group, 1e6)
} }
}
return help() return help()
} }
@@ -391,6 +438,7 @@ class DelayManager {
await Promise.all(promiseList) await Promise.all(promiseList)
} finally { } finally {
// Always release the batch and notify; otherwise failures leave stale sort state. // Always release the batch and notify; otherwise failures leave stale sort state.
if (generation === this.generation) {
const remaining = (this.activeBatches.get(group) ?? 1) - 1 const remaining = (this.activeBatches.get(group) ?? 1) - 1
if (remaining > 0) { if (remaining > 0) {
this.activeBatches.set(group, remaining) this.activeBatches.set(group, remaining)
@@ -399,6 +447,7 @@ class DelayManager {
this.queueGroupNotification(group) this.queueGroupNotification(group)
} }
} }
}
const totalTime = Date.now() - startTime const totalTime = Date.now() - startTime
debugLog( debugLog(
`[DelayManager] 批量测试延迟完成,组: ${group}, 总耗时: ${totalTime}ms`, `[DelayManager] 批量测试延迟完成,组: ${group}, 总耗时: ${totalTime}ms`,