Files
clash-verge-rev/src/services/delay.ts
T
Tunglies 62666b5e27 fix(proxies): derive mounted delays from shared snapshots
Remove mirrored item delay state so profile cache invalidation cannot leave reused rows blank until remount. Read current node history when the shared cache is empty and preserve snapshot identity for React subscriptions.
2026-11-11 05:47:22 +08:00

491 lines
14 KiB
TypeScript

import {
delayProxyByName,
healthcheckNodeInProvider,
type ProxyDelay,
} from 'tauri-plugin-mihomo-api'
import {
memberDetails,
providerNameOf,
type InteractableProxyMember,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
import { isValidUrl } from '@/utils/network'
export type DelaySnapshot = {
of: (member: ResolvedProxyMember) => number
}
const hashKey = (name: string, group: string) => `${group ?? ''}::${name}`
export interface DelayUpdate {
delay: number
elapsed?: number
updatedAt: number
}
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>()
private listenerMap = new Map<string, Set<(update: DelayUpdate) => void>>()
private groupListenerMap = new Map<string, Set<() => void>>()
// Consumers compare snapshot identity; replace it only when the group settles.
private groupSnapshots = new Map<string, DelaySnapshot>()
private groupSetSnapshots = new Map<
string,
ReadonlyMap<string, DelaySnapshot>
>()
// Suppress sort notifications until every measurement in a group batch settles.
private activeBatches = new Map<string, number>()
private pendingItemUpdates = new Map<string, DelayUpdate[]>()
private pendingGroupUpdates = new Set<string>()
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, 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 {
if (typeof window !== 'undefined') {
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(run)
return
}
if (typeof window.setTimeout === 'function') {
window.setTimeout(run, 0)
return
}
}
Promise.resolve().then(run)
}
private scheduleItemFlush() {
if (this.itemFlushScheduled) return
this.itemFlushScheduled = true
this.scheduleOnNextFrame(() => {
this.itemFlushScheduled = false
const updates = this.pendingItemUpdates
this.pendingItemUpdates = new Map()
updates.forEach((queue, key) => {
const listeners = this.listenerMap.get(key)
if (!listeners) return
queue.forEach((update) => {
for (const listener of [...listeners]) {
try {
listener(update)
} catch (error) {
console.error(
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
error,
)
}
}
})
})
})
}
private scheduleGroupFlush() {
if (this.groupFlushScheduled) return
this.groupFlushScheduled = true
this.scheduleOnNextFrame(() => {
this.groupFlushScheduled = false
const groups = this.pendingGroupUpdates
this.pendingGroupUpdates = new Set()
groups.forEach((group) => {
const listeners = this.groupListenerMap.get(group)
if (!listeners) return
// Copied before iterating: a listener is free to unsubscribe as it runs.
for (const listener of [...listeners]) {
try {
listener()
} catch (error) {
console.error(
`[DelayManager] 通知分组延迟监听器失败: ${group}`,
error,
)
}
}
})
})
}
private queueGroupNotification(group: string) {
if ((this.activeBatches.get(group) ?? 0) > 0) return
// Invalidate the set wrapper while retaining unaffected per-group identities.
this.groupSnapshots.delete(group)
this.groupSetSnapshots.clear()
this.pendingGroupUpdates.add(group)
this.scheduleGroupFlush()
}
/** Cached set snapshot for `useSyncExternalStore` identity comparisons. */
groupsDelays(groupKey: string): ReadonlyMap<string, DelaySnapshot> {
const cached = this.groupSetSnapshots.get(groupKey)
if (cached) return cached
const names = groupKey ? groupKey.split(' ') : []
const snapshots = new Map(
names.map((name) => [name, this.groupDelays(name)]),
)
this.groupSetSnapshots.set(groupKey, snapshots)
return snapshots
}
/** Cached group snapshot whose identity changes only when that group settles. */
groupDelays(group: string): DelaySnapshot {
const existing = this.groupSnapshots.get(group)
if (existing) return existing
const snapshot: DelaySnapshot = {
of: (member) => this.getDelayFix(member, group),
}
this.groupSnapshots.set(group, snapshot)
return snapshot
}
setUrl(group: string, url: string) {
if (!isValidUrl(url)) {
debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`)
this.urlMap.delete(group)
return
}
debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`)
this.urlMap.set(group, url)
}
getUrl(group: string) {
const url = this.urlMap.get(group)
debugLog(
`[DelayManager] 获取测试URL,组: ${group}, URL: ${url || '未设置'}`,
)
return url || 'http://cp.cloudflare.com/generate_204'
}
addListener(
name: string,
group: string,
listener: (update: DelayUpdate) => void,
): () => void {
const key = hashKey(name, group)
const listeners = this.listenerMap.get(key) ?? new Set()
listeners.add(listener)
this.listenerMap.set(key, listeners)
return () => {
const current = this.listenerMap.get(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. */
addGroupListener(group: string, listener: () => void): () => void {
const listeners = this.groupListenerMap.get(group) ?? new Set()
listeners.add(listener)
this.groupListenerMap.set(group, listeners)
return () => {
const current = this.groupListenerMap.get(group)
if (!current) return
current.delete(listener)
if (current.size === 0) this.groupListenerMap.delete(group)
}
}
setDelay(
name: string,
group: string,
delay: number,
meta?: { elapsed?: number },
): DelayUpdate {
const key = hashKey(name, group)
debugLog(
`[DelayManager] 设置延迟,代理: ${name}, 组: ${group}, 延迟: ${delay}`,
)
const update: DelayUpdate = {
delay,
elapsed: meta?.elapsed,
updatedAt: Date.now(),
}
this.cache.set(key, update)
const queue = this.pendingItemUpdates.get(key)
if (queue) {
queue.push(update)
} else {
this.pendingItemUpdates.set(key, [update])
}
this.scheduleItemFlush()
return update
}
getDelayUpdate(name: string, group: string) {
const key = hashKey(name, group)
const entry = this.cache.get(key)
if (!entry) return undefined
if (Date.now() - entry.updatedAt > CACHE_TTL) {
this.cache.delete(key)
return undefined
}
// React's external-store reads must retain identity until the value changes.
return entry
}
getDelay(name: string, group: string) {
const update = this.getDelayUpdate(name, group)
return update ? update.delay : -1
}
getDelayFix(member: ResolvedProxyMember, group: string) {
if (member.kind === 'unresolved') return -1
const details = memberDetails(member)
const name = member.ref.name
const update = this.getDelayUpdate(name, group)
if (update && (update.delay >= 0 || update.delay === -2)) {
return update.delay
}
if (details?.history && details.history.length > 0) {
return details.history[details.history.length - 1].delay || 1e6
}
return -1
}
async unifiedDelayCheck(
name: string,
url: string,
timeout: number,
providerName?: string,
) {
if (providerName)
return healthcheckNodeInProvider(providerName, name, url, timeout)
return delayProxyByName(name, url, timeout)
}
/** A single test may notify immediately; a batch defers notification until it settles. */
async checkDelay(
member: InteractableProxyMember,
group: string,
timeout: number,
): Promise<void> {
const generation = this.generation
// Entry points own the pending mark; measureDelay only writes results.
this.setDelay(member.ref.name, group, -2)
await this.measureDelay(member, group, timeout, generation)
if (generation === this.generation) this.queueGroupNotification(group)
}
private async measureDelay(
member: InteractableProxyMember,
group: string,
timeout: number,
generation: number,
): Promise<void> {
if (generation !== this.generation) return
const name = member.ref.name
const providerName =
member.kind === 'node' ? providerNameOf(member.node) : undefined
const apiName =
member.kind === 'node' && member.node.source.kind === 'provider'
? member.node.source.proxyName
: name
debugLog(
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
)
const startTime = Date.now()
try {
const url = this.getUrl(group)
debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`)
const timeoutPromise = new Promise<ProxyDelay>((resolve) => {
setTimeout(() => resolve({ delay: 0 }), timeout)
})
const result = await Promise.race([
this.unifiedDelayCheck(apiName, url, timeout, providerName),
timeoutPromise,
])
const elapsedTime = Date.now() - startTime
if (elapsedTime < 500) {
await new Promise((resolve) => setTimeout(resolve, 500 - elapsedTime))
}
const delay = result.delay
const elapsed = elapsedTime
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
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
if (generation === this.generation) {
this.setDelay(name, group, delay, { elapsed })
}
}
}
async checkListDelay(
proxies: InteractableProxyMember[],
group: string,
timeout: number,
concurrency = 36,
) {
const generation = this.generation
debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
)
const names = proxies.map((member) => member.ref.name)
this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1)
names.forEach((name) => {
this.setDelay(name, group, -2)
})
let index = 0
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
try {
// Stagger requests so a batch does not hit the core at once.
if (index > 1) {
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200),
)
}
// Do not reorder a list around the pointer while batch results are arriving.
await this.measureDelay(currMember, group, timeout, generation)
} catch (error) {
console.error(
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error,
)
if (generation === this.generation) {
this.setDelay(currName, group, 1e6)
}
}
return help()
}
const actualConcurrency = Math.min(concurrency, names.length, 10)
debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`)
const promiseList: Promise<void>[] = []
for (let i = 0; i < actualConcurrency; i++) {
promiseList.push(help())
}
try {
await Promise.all(promiseList)
} finally {
// Always release the batch and notify; otherwise failures leave stale sort state.
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
debugLog(
`[DelayManager] 批量测试延迟完成,组: ${group}, 总耗时: ${totalTime}ms`,
)
}
formatDelay(delay: number, timeout = DEFAULT_DELAY_TIMEOUT) {
switch (classifyDelay(delay, timeout)) {
case 'untested':
return '-'
case 'testing':
return 'testing'
case 'timeout':
return 'Timeout'
case 'error':
return 'Error'
case 'measured':
return `${delay}`
}
}
formatDelayColor(delay: number, timeout = DEFAULT_DELAY_TIMEOUT) {
switch (classifyDelay(delay, timeout)) {
case 'untested':
case 'testing':
return ''
case 'timeout':
case 'error':
return 'error.main'
case 'measured':
// Colour and signal bars intentionally use different grading thresholds.
if (delay >= 400) return 'warning.main'
if (delay >= 250) return 'primary.main'
return 'success.main'
}
}
}
export default new DelayManager()