fix: prevent subscription update storms

This commit is contained in:
ezequielnick
2026-04-29 11:59:24 +08:00
parent b7c9e4aa5c
commit 4051cf4c92
3 changed files with 85 additions and 81 deletions

View File

@@ -1,7 +1,8 @@
# 1.9.4
# 1.9.5
## 新功能 (Feat)
- 更新 mihomo 内核
- 新增每个订阅独立的 User-Agent 配置
- 使用 mshta 在 Electron 初始化前同步检测 PowerShell 版本
@@ -9,6 +10,7 @@
- 修复 Win7 兼容性问题
- 修复日志清理正则表达式以正确匹配带前缀的文件名
- 修复订阅自动更新在异常间隔或错误配置下可能触发高频刷新请求的问题
## 其他 (Chore)

View File

@@ -258,6 +258,9 @@ interface FetchResult {
headers: Record<string, string>
}
const MAX_TIMER_DELAY_MS = 2_147_483_647
const MAX_PROFILE_INTERVAL_MINUTES = Math.floor(MAX_TIMER_DELAY_MS / (60 * 1000))
async function fetchAndValidateSubscription(options: FetchOptions): Promise<FetchResult> {
const { url, useProxy, mixedPort, userAgent, authToken, timeout, substore } = options
@@ -377,10 +380,9 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
newItem.home = headers['profile-web-page-url']
}
if (headers['profile-update-interval'] && !item.allowFixedInterval) {
// 拒绝等异常值
const hours = parseInt(headers['profile-update-interval'], 10)
const hours = Number(headers['profile-update-interval'])
if (Number.isFinite(hours) && hours > 0) {
newItem.interval = hours * 60
newItem.interval = Math.min(Math.ceil(hours * 60), MAX_PROFILE_INTERVAL_MINUTES)
}
}
if (headers['subscription-userinfo']) {

View File

@@ -4,21 +4,87 @@ import { logger } from '../utils/logger'
const intervalPool: Record<string, Cron | NodeJS.Timeout> = {}
const delayedUpdatePool: Record<string, NodeJS.Timeout> = {}
const updatingProfileIds = new Set<string>()
// 定时触发的订阅刷新至少间隔1分钟
const MIN_INTERVAL_MS = 60 * 1000
const MAX_TIMER_DELAY_MS = 2_147_483_647
function safeIntervalMs(minutes: unknown): number {
const requestedMs = Number(minutes) * 60 * 1000
if (!Number.isFinite(requestedMs) || requestedMs <= 0) return MIN_INTERVAL_MS
return Math.max(MIN_INTERVAL_MS, requestedMs)
return Math.min(Math.max(requestedMs, MIN_INTERVAL_MS), MAX_TIMER_DELAY_MS)
}
function intervalDelayMs(interval: unknown): number | undefined {
const minutes = Number(interval)
if (!Number.isFinite(minutes) || minutes <= 0) return undefined
return safeIntervalMs(minutes)
}
async function updateProfile(id: string): Promise<void> {
if (updatingProfileIds.has(id)) return
updatingProfileIds.add(id)
try {
const item = await getProfileItem(id)
if (item && item.type === 'remote') {
await addProfileItem(item)
}
} finally {
updatingProfileIds.delete(id)
}
}
function updateTask(itemId: string, logLabel: string): () => Promise<void> {
return async () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update ${logLabel}:`, e)
}
}
}
function scheduleProfileUpdate(item: IProfileItem): void {
if (item.type !== 'remote' || !item.autoUpdate || !item.interval) return
const itemId = item.id
const logLabel = `profile ${itemId}`
const delayMs = intervalDelayMs(item.interval)
if (delayMs) {
intervalPool[itemId] = setInterval(updateTask(itemId, logLabel), delayMs)
return
}
if (typeof item.interval !== 'string') return
const cronExpression = item.interval.trim()
// 只接受 5 段 cron6 段 cron 带秒,会绕过 UI 造成秒级刷新。
if (cronExpression.split(/\s+/).length !== 5) return
try {
intervalPool[itemId] = new Cron(cronExpression, updateTask(itemId, logLabel))
} catch {
// ignore invalid cron
}
}
function scheduleDelayedCurrentUpdate(item: IProfileItem): void {
const delayMs = intervalDelayMs(item.interval)
if (!delayMs) return
const itemId = item.id
delayedUpdatePool[itemId] = setTimeout(
async () => {
delete delayedUpdatePool[itemId]
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
}
},
Math.min(delayMs + 10000, MAX_TIMER_DELAY_MS)
)
}
export async function initProfileUpdater(): Promise<void> {
@@ -27,24 +93,7 @@ export async function initProfileUpdater(): Promise<void> {
for (const item of items.filter((i) => i.id !== current)) {
if (item.type === 'remote' && item.autoUpdate && item.interval) {
const itemId = item.id
if (typeof item.interval === 'number') {
intervalPool[itemId] = setInterval(async () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
}
}, safeIntervalMs(item.interval))
} else if (typeof item.interval === 'string') {
intervalPool[itemId] = new Cron(item.interval, async () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
}
})
}
await addProfileUpdater(item)
try {
await addProfileItem(item)
@@ -56,71 +105,22 @@ export async function initProfileUpdater(): Promise<void> {
if (currentItem?.type === 'remote' && currentItem.autoUpdate && currentItem.interval) {
const currentId = currentItem.id
if (typeof currentItem.interval === 'number') {
const currentMs = safeIntervalMs(currentItem.interval)
intervalPool[currentId] = setInterval(async () => {
try {
await updateProfile(currentId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
}
}, currentMs)
delayedUpdatePool[currentId] = setTimeout(async () => {
delete delayedUpdatePool[currentId]
try {
await updateProfile(currentId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
}
}, currentMs + 10000)
} else if (typeof currentItem.interval === 'string') {
intervalPool[currentId] = new Cron(currentItem.interval, async () => {
try {
await updateProfile(currentId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
}
})
}
await addProfileUpdater(currentItem)
try {
await addProfileItem(currentItem)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to init current profile:`, e)
}
const latestCurrentItem = (await getProfileItem(currentId)) ?? currentItem
scheduleDelayedCurrentUpdate(latestCurrentItem)
}
}
export async function addProfileUpdater(item: IProfileItem): Promise<void> {
if (item.type === 'remote' && item.autoUpdate && item.interval) {
if (intervalPool[item.id]) {
if (intervalPool[item.id] instanceof Cron) {
;(intervalPool[item.id] as Cron).stop()
} else {
clearInterval(intervalPool[item.id] as NodeJS.Timeout)
}
}
const itemId = item.id
if (typeof item.interval === 'number') {
intervalPool[itemId] = setInterval(async () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
}
}, safeIntervalMs(item.interval))
} else if (typeof item.interval === 'string') {
intervalPool[itemId] = new Cron(item.interval, async () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
}
})
}
}
await removeProfileUpdater(item.id)
scheduleProfileUpdate(item)
}
export async function removeProfileUpdater(id: string): Promise<void> {